Choices and Aggregates
Facts, rules, and constraints are deterministic — given them, the answer set is fixed. This page is where you hand the solver a real decision, and then count, sum, and bound what it decides.
Two constructs do the work, and they pair up. A choice rule is the generate step: it offers the solver a set of candidate atoms and lets it pick a subset. Aggregates are the test step: they count, sum, and take extremes over atoms — usually the ones a choice just generated — so you can insist the result stays within bounds. Generate a space of possibilities, then prune it: that’s the shape of most ASP programs, and the shape of this page.
We’ll build one running example — assigning a workshop’s tasks to its workers.
Choice rules
A choice rule hands the solver a decision: from a set of candidate atoms, make some subset true.
Choice(element, condition=...) builds one. The element is the atom being chosen, and the condition
says where the candidates come from — it’s a conditional literal, so
you get one candidate per way the condition can be satisfied. Cap the decision with exactly(),
at_least(), or at_most(), and attach it to a rule with when(...).derive(...):
from aspalchemy import ASPProgram, Choice, Field, Predicate, Variable
class Task(Predicate, show=False):
name: Field[str]
class Worker(Predicate, show=False):
name: Field[str]
class Assigned(Predicate):
task: Field[str]
worker: Field[str]
T, W = Variable("T"), Variable("W")
program = ASPProgram()
program.fact(*(Task(name=t) for t in ["wiring", "plumbing", "painting"]))
program.fact(*(Worker(name=w) for w in ["ada", "grace"]))
# Every task goes to exactly one worker
program.when(Task(name=T)).derive(
Choice(Assigned(task=T, worker=W), condition=Worker(name=W)).exactly(1)
)
>>> print(program.render())
% Generated by aspalchemy ...
task("wiring").
task("plumbing").
task("painting").
worker("ada").
worker("grace").
{ assigned(T, W) : worker(W) } = 1 :- task(T).
#show.
#show assigned/2.
Read that last line as: for each task T, pick exactly one assigned atom, drawing the worker W from
the workers we know about. The three cardinality methods render the way clingo spells them — exactly(1)
is { ... } = 1, at_least(2).at_most(4) is 2 { ... } 4, and with no bounds at all the subset is free:
class OnCall(Predicate):
worker: Field[str]
>>> banded = Choice(Assigned(task=T, worker=W), condition=Worker(name=W)).at_least(2).at_most(4)
>>> banded.render()
'2 { assigned(T, W) : worker(W) } 4'
A choice that needs no conditions attaches directly with choose(), and holds unconditionally:
# A bare choice: no conditions, holds unconditionally
standby = Choice(OnCall(worker=W), condition=Worker(name=W)).at_least(1)
program.choose(standby)
>>> standby.render() # in the program, a rule of its own: '1 { on_call(W) : worker(W) }.'
'1 { on_call(W) : worker(W) }'
A single choice can offer more than one kind of atom: add(element, condition=...) appends another, and
the cardinality then bounds the whole set. The bounds are checked as you build — a negative bound, or
at_least(3).at_most(2), is refused right at the Python line rather than quietly becoming an unsatisfiable
program:
class Approve(Predicate):
task: Field[str]
class Defer(Predicate):
task: Field[str]
>>> decision = Choice(Approve(task=T))
>>> decision.add(Defer(task=T)) # add() mutates, and returns nothing
>>> decision.render()
'{ approve(T); defer(T) }'
# Solve and check the choices were honored
model = program.solve().first()
for t in ["wiring", "plumbing", "painting"]:
assert sum(1 for a in model.atoms(Assigned) if a.task == t) == 1
assert len(model.atoms(OnCall)) >= 1
And the bounds needn’t be literal integers: a Variable or Expression bound in the rule’s conditions
works too, so your data can decide how many atoms to choose.
A choice is a value
A Choice is an ordinary Python value you can name, reuse, and share between rules — but its two halves,
the bounds and the elements, behave differently, and the difference is worth a minute.
Bounds hand back a new choice. exactly(), at_least(), and at_most() don’t touch the choice you
call them on — each returns a fresh choice carrying the bound. That’s what lets one menu be bounded two
ways:
>>> menu = Choice(OnCall(worker=W), condition=Worker(name=W))
>>> weekday, weekend = menu.exactly(1), menu.exactly(2)
>>> weekday.render()
'{ on_call(W) : worker(W) } = 1'
>>> weekend.render()
'{ on_call(W) : worker(W) } = 2'
>>> menu.render() # the original is untouched, and still reusable
'{ on_call(W) : worker(W) }'
Elements are added by mutation. add() appends to this choice and returns nothing — the same
contract as list.append, so it can’t be mistaken for a value. That raises a hazard: once a rule has
captured a choice, mutating it afterward would silently rewrite the rule already on record. So a captured
choice freezes, and add() refuses — naming the file and line of the rule that captured it:
>>> standby.add(OnCall(worker=W)) # standby was captured by program.choose() above
Traceback (most recent call last):
...
RuntimeError: This Choice was captured by the rule at ... and is frozen; mutating it would silently rewrite the recorded rule. Call .copy() for a fresh, mutable Choice with the same elements, or build a new Choice.
The error names the way out. copy() returns an independent, mutable choice with the same elements; since
no rule holds the copy, building on it can’t rewrite anything already recorded:
>>> extended = standby.copy() # a fresh, mutable Choice
>>> extended.add(OnCall(worker="hot_spare"))
>>> extended.render()
'1 { on_call(W) : worker(W); on_call("hot_spare") }'
>>> standby.render() # the captured one is untouched
'1 { on_call(W) : worker(W) }'
Bounding a frozen choice is fine for the same reason — it rewrites nothing and hands back a new choice
(which is exactly why exactly() and friends are built on copy()). And freezing fences only mutation:
a frozen choice is still a value, so more rules may capture and share it, rendering identically in each.
That file:line in the error is part of the library’s habit that every error
teaches.
class Weekend(Predicate, show=False):
pass
rota = ASPProgram()
rota.fact(Worker(name="ada"), Weekend())
cover = Choice(OnCall(worker=W), condition=Worker(name=W)).exactly(1)
rota.choose(cover) # captured (and frozen) here...
rota.when(Weekend()).derive(cover) # ...and shared here, legally
>>> print(rota.render())
% Generated by aspalchemy ...
worker("ada").
weekend.
{ on_call(W) : worker(W) } = 1.
{ on_call(W) : worker(W) } = 1 :- weekend.
#show.
#show on_call/1.
Aggregates
That’s the generate step; aggregates are the test. An aggregate reduces a set of atoms to a single value
you can then bound. There are five — Count, Sum, SumPlus, Min, and Max — and they all share the
grammar of a choice element: an element (or tuple of terms) with an optional condition, plus add() for
further elements.
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, Count, Variable
X = Variable("X")
count = Count(X, Person(name=X, age=ANY)) > 5
Count counts distinct matching tuples. The other four reduce the first term of each tuple: Sum adds
it up (SumPlus is the same, except negative weights count as zero), and Min and Max take the
extremes. So a tuple’s leading term is its weight, and any trailing terms are there only to keep tuples
distinct — Sum((A, X), Person(name=X, age=A)) sums one age per person, even when two people happen to
share an age:
>>> from aspalchemy import Max, Sum
>>> A = Variable("A")
>>> total_age = Sum((A, X), Person(name=X, age=A))
>>> total_age.render()
'#sum{ A, X : person(X, A) }'
>>> oldest = Max(A, Person(name=ANY, age=A))
>>> oldest.render()
'#max{ A : person(_, A) }'
An aggregate never stands on its own. It becomes usable only inside a comparison — the guard — and that
comparison then drops in wherever comparisons go: when() conditions, forbid(), require(). The count
built above is already a full guard; Count(...) > 5 is just a comparison
that happens to hold an aggregate. Handing a bare aggregate to a rule as though it were an atom is refused
at construction.
census = ASPProgram()
census.fact(john, mary, Person(name="alan", age=41))
census.require(Count(X, Person(name=X, age=ANY)) >= 3) # holds: 3 people
census.solve().first() # would raise UnsatisfiableError if the guard failed
Guards done right
A one-sided guard is a single comparison. A band — the count has to land between two bounds — is two comparisons over the same aggregate. The rule of thumb: build the aggregate once, then compare it twice.
# One-sided: no worker carries more than two tasks
load = Count(T, Assigned(task=T, worker=W))
program.forbid(Worker(name=W), load > 2)
# A band: total assignments between 3 and 6 — two comparisons, same aggregate
class Balanced(Predicate):
pass
workload = Count((T, W), Assigned(task=T, worker=W))
program.when(workload >= 3, workload <= 6).derive(Balanced())
>>> print(program.render())
% Generated by aspalchemy ...
task("wiring").
task("plumbing").
task("painting").
worker("ada").
worker("grace").
{ assigned(T, W) : worker(W) } = 1 :- task(T).
1 { on_call(W) : worker(W) }.
:- worker(W), #count{ T : assigned(T, W) } > 2.
balanced :- #count{ T, W : assigned(T, W) } >= 3, #count{ T, W : assigned(T, W) } <= 6.
#show.
#show assigned/2.
#show balanced/0.
#show on_call/1.
>>> model = program.solve().first()
>>> bool(model.atoms(Balanced)) # 3 tasks, one worker each: the band holds
True
The render repeats the aggregate on both sides of the band, which looks wasteful. It isn’t: gringo
(clingo’s grounder — the half that instantiates your rules over the data) recognizes the shared aggregate
and grounds it once, so the two comparisons cost no more than clingo’s native interval literal
(3 <= #count{...} <= 6, a spelling ASPAlchemy deliberately
doesn’t model).
What you do want to avoid is bind-then-compare: binding the count to a variable — N == Count(...) —
and then testing N against the bounds. It reads like one aggregate, but it forces gringo to ground a
fresh aggregate for every feasible value of N, with the band’s width as the multiplier. Keep the variable
out of it: compare the aggregate directly, once per bound. (A first-class banded form is on the wishlist;
until then, two comparisons is the idiom, and it costs nothing.)
Cardinality tests are not choices
One last place the choice syntax can trip you. In clingo, braces in what a rule derives mean choose —
but the very same braces among a rule’s conditions, 2 { p(X) } 4 :- q., mean something completely
different: a cardinality test, counting how many p(X) are true. Nothing is chosen. (That’s the
head-versus-body split from Statements and Terms; the error below names
it in clingo’s own terms.) So ASPAlchemy refuses a Choice used as a condition, and the message points at
the right spelling:
>>> program.forbid(Choice(OnCall(worker=W), condition=Worker(name=W)).at_least(3))
Traceback (most recent call last):
...
ValueError: A Choice belongs in a rule head, where braces CHOOSE. In a body, clingo's braces mean a cardinality TEST — a different construct aspalchemy spells as a Count comparison: Count(X, condition=...) >= n.
A cardinality test is a Count guard — which is exactly what it means — and it grounds identically to
the brace form:
# "At most two workers on call" — a cardinality test, spelled as one
program.require(Count(W, OnCall(worker=W)) <= 2)
model = program.solve().first()
assert len(model.atoms(OnCall)) <= 2
A two-sided test (2 { p(X) } 4 among conditions) is just the band idiom from
above: two comparisons over one Count. Keeping the condition-brace syntax out is a
deliberate strictness — one syntax should mean one thing, and here
braces mean choose, which is why they belong only in what a rule derives.