Practice
Three escalating demos: two theories cooperating, two theories failing to cooperate, the protocol up close.
Where We Left Off
Last week we surveyed four theory solvers (LRA, LIA, BV, arrays), and the week before we opened EUF all the way up. Each solver was introduced in isolation, deciding a conjunction in its own theory.
Real verification problems aren't that clean. A heap-manipulating routine touches arrays, integer indices, and uninterpreted helper functions all at once. A compiler optimization mixes integer arithmetic with bitvectors. A spec written in equational style spans equality and arithmetic.
What does Z3 actually do when the formula has atoms from two, three, four different theories? Today we watch that cooperation in three escalating acts.
The catch phrase from earlier weeks is still here, sharper than ever. If you get the reduction wrong, the solver gives you a correct answer to the wrong question. Today's reduction is the one the solvers themselves perform on each other's facts: theories pass notes through equality.
Act 1: Heap Routine Equivalence
The problem
Every working programmer has shipped code that mutates an array through index arithmetic. A common pattern: in-place swap. There are two well-known implementations.
Variant A, with a temporary:
t = a[i];
a[i] = a[j];
a[j] = t;
Variant B, no temporary, using arithmetic:
a[i] = a[i] + a[j];
a[j] = a[i] - a[j];
a[i] = a[i] - a[j];
Engineers reach for variant B in interviews and embedded systems where every byte counts. The first question for any candidate implementation: are the two variants equivalent for all inputs?
Warmup: swap on plain variables
Before mixing in arrays, ask the question on plain variables. No
indices, no aliasing, just two integer cells named x and y.
The pseudocode mutates names; Z3 expressions don't. We bridge with SSA: each pseudocode step assigns to a fresh name. Same trick we'll use for the array version below, just without indexing.
def variable_swap_with_temp(x, y):
# Pseudocode: SSA in Python:
# t = x t = x
# x = y x1 = y
# y = t y1 = t
t = x
x1 = y
y1 = t
return (x1, y1)
def variable_swap_arithmetic(x, y):
# Pseudocode: SSA in Python:
# x = x + y x1 = x + y
# y = x - y y1 = x1 - y
# x = x - y x2 = x1 - y1
x1 = x + y
y1 = x1 - y
x2 = x1 - y1
return (x2, y1)
Verifying equivalence is a counterexample search: ask Z3 to find inputs where the two variants disagree. If it cannot, they're equivalent.
x_in, y_in = Int('x_in'), Int('y_in')
xA, yA = variable_swap_with_temp(x_in, y_in)
xB, yB = variable_swap_arithmetic(x_in, y_in)
s = Solver()
s.add(Or(xA != xB, yA != yB))
print(s.check()) # unsat
unsat. The two variants agree on every pair of integer inputs.
Two theories were involved (Int for the arithmetic, plus the
boolean combinators Or and !=), but no array reasoning yet.
We're just warming up.
Same question on arrays
Now move to arrays. The pseudocode uses a[i] and a[j] instead
of x and y. The encoding uses Store and Select.
Z3 arrays are functional. Store(a, i, v) returns a new array
that agrees with a everywhere except at index i, where it has
value v. Select(a, i) reads index i. We don't mutate; we
thread the array state through fresh names a1, a2, a3,
exactly the SSA pattern we used above, just in array land.
def array_swap_with_temp(a, i, j):
# Pseudocode: SSA in Python:
# t = a[i] t = Select(a, i)
# a[i] = a[j] a1 = Store(a, i, Select(a, j))
# a[j] = t a2 = Store(a1, j, t)
t = Select(a, i)
a1 = Store(a, i, Select(a, j))
a2 = Store(a1, j, t)
return a2
def array_swap_arithmetic(a, i, j):
# Pseudocode: SSA in Python:
# a[i] = a[i] + a[j] a1 = Store(a, i, Select(a, i) + Select(a, j))
# a[j] = a[i] - a[j] a2 = Store(a1, j, Select(a1, i) - Select(a1, j))
# a[i] = a[i] - a[j] a3 = Store(a2, i, Select(a2, i) - Select(a2, j))
a1 = Store(a, i, Select(a, i) + Select(a, j))
a2 = Store(a1, j, Select(a1, i) - Select(a1, j))
a3 = Store(a2, i, Select(a2, i) - Select(a2, j))
return a3
Same counterexample search: ask Z3 if there are inputs where the two variants disagree.
a = Array('a', IntSort(), IntSort())
i, j = Int('i'), Int('j')
resultA = array_swap_with_temp(a, i, j)
resultB = array_swap_arithmetic(a, i, j)
s = Solver()
s.add(resultA != resultB)
print(s.check()) # sat
print(s.model()) # i = 3, j = 3, a[3] = -1
sat. Z3 found a witness:
counterexample: i = 3, j = 3
before: a[3] = -1, a[3] = -1
variant A: a[3] = -1, a[3] = -1
variant B: a[3] = 0, a[3] = 0 ← differs
When i == j, both indices refer to the same cell. Variant A
loads a[i] into a temporary, writes a[j] into a[i] (no-op,
since they're the same cell), and writes the temp back into a[j]
(also a no-op). The cell is unchanged.
Variant B is not so lucky. With i == j, the first step doubles
the cell: a[i] = a[i] + a[j] = 2 * a[i]. The second writes
a[i] - a[j] = 0. The third writes a[i] - a[j] = 0 again. The
cell is zeroed.
This is a real bug. The arithmetic swap is correct only when the two indices are distinct.
A precondition that restores equivalence
Add i != j as a precondition and re-run.
s = Solver()
s.add(i != j)
s.add(resultA != resultB)
print(s.check()) # unsat
unsat. Under the no-aliasing precondition, the two variants
agree.
What just happened
Z3 just coordinated two theory solvers to find this bug.
-
Array reasoning compared two whole arrays and noticed they disagreed at some index. Comparing arrays via
!=invokes the array theory's extensionality axiom: two arrays are equal iff they agree at every index. -
Integer reasoning found the witness
i == jand evaluated the arithmetic that zeros the cell. The bug only matters when the indices alias, which is an integer fact.
Neither solver alone could find this counterexample. The array reasoner doesn't know that arithmetic on indices can produce collisions; the integer reasoner doesn't know what an array is. Together, they found it in milliseconds.
This is what cooperation means in SMT, in miniature. Two theory solvers agree on a single counterexample candidate, and the candidate satisfies both. The next act asks: when can this cooperation fail to converge?
Act 2: The Cooperation Gap
A formula with two faces
Here is a small mixed-theory formula:
where is an integer and is an uninterpreted function on integers. The formula has two natural pieces. The first half is integer arithmetic on ; the second half is equality reasoning involving .
Predict before running
Look at the formula again. Before reading on, predict: is the combined conjunction satisfiable? What about each half on its own? Hold your prediction.
Each piece alone
Run each piece by itself.
x = Int('x')
f = Function('f', IntSort(), IntSort())
int_piece = [1 <= x, x <= 2]
euf_piece = [f(x) != f(1), f(x) != f(2)]
The integer piece, on its own:
formula: 1 ≤ x ∧ x ≤ 2
Z3: sat
witness: x = 1
Satisfiable. The integer reasoner picks , inside the required range.
The equality piece, on its own:
formula: f(x) ≠ f(1) ∧ f(x) ≠ f(2)
Z3: sat
witness: x = 0
f cooked up by Z3:
f(0) = 3
f(1) = 4
f(2) = 5
Also satisfiable. The equality reasoner is free to pick any and any interpretation of it likes. Z3 picks and cooks up an that maps to three distinct values so the two disequalities hold. All four constraints in the equality piece are honored.
The combined formula
Now the conjunction:
formula: 1 ≤ x ∧ x ≤ 2 ∧ f(x) ≠ f(1) ∧ f(x) ≠ f(2)
Z3: unsat
Combined: unsatisfiable. There is no and no that satisfy all four constraints at once.
The reasoning is short. Once the integer reasoner has , it knows is one of . If , then by function congruence, contradicting . If , then by congruence, contradicting . Both branches close. The formula is UNSAT.
But notice what the integer reasoner had to share. It didn't pass the equality reasoner a single fact like "." It would have liked to; that's how Act 1's two solvers got to a counterexample. But here it doesn't know whether or . What it knows is the disjunction .
Hold this thought
This formula is a hint that simple equality propagation isn't always enough. Sometimes the integer reasoner can only offer a disjunction of equalities, and the equality reasoner has to try each branch.
That move (case-splitting on a disjunction of equalities) is the non-convex extension of the cooperation algorithm. We will return to this exact formula at the end of Theory and trace both branches end to end.
For now, hold the formula in your head. Two pieces, each satisfiable, combined unsatisfiable, glued together by an implicit disjunction.
Act 3: Live Nelson-Oppen Walkthrough
The formula
The cleanest cooperation example in the textbook is from Bradley and Manna, with roots in Nelson and Oppen's original 1979 paper:
over the reals, with uninterpreted. The constraints are straightforward. Three real-arithmetic inequalities pin down a relationship between , , and . The single equality constraint says , applied to a specific term, differs from applied to .
This formula is unsatisfiable. The argument is interesting: it takes three notes passed back and forth between the real-arithmetic reasoner and the equality reasoner, each note an equality, each one verified mechanically before being passed.
Purification first (briefly)
The two reasoners cannot read each other's terms directly. The
equality reasoner doesn't know what <= means; the real-arithmetic
reasoner doesn't know what f does. We pre-process the formula
to separate it into two pure conjunctions.
The pre-processing introduces fresh names for the cross-theory subterms. We call these shared constants: they appear in both sides and are the channel through which equalities will flow.
After this rewrite (Theory §4 makes the rules precise) the formula splits cleanly into:
is pure linear real arithmetic. is pure equality with uninterpreted functions. The shared constants (and the originals ) appear on both sides.
The propagation chain
The whole walkthrough fits on one diagram. Three equalities cross the boundary, then the fourth note is a contradiction.
sequenceDiagram
accTitle: Nelson-Oppen propagation chain on the worked example
accDescr: Σ_R and Σ_= exchange three equalities, then Σ_= reports unsat.
autonumber
participant R as Σ_R (LRA)
participant E as Σ_= (EUF)
Note over R,E: each side satisfiable on its own
R->>E: x = y
Note right of E: f(x) = f(y) by congruence, so u = v
E->>R: u = v
Note left of R: w = u − v = 0, and z = 0, so w = z
R->>E: w = z
Note right of E: f(w) = f(z) by congruence, contradicts f(w) ≠ f(z)
Note over R,E: UNSATNow walk through it. Run each reasoner on its own piece and watch what facts they learn about the shared constants.
Sanity check. Each side, by itself, is satisfiable. Neither solver alone has any reason to declare UNSAT.
Step 1. The real-arithmetic reasoner looks at and notices something. From and , we get , so . But also , so . Substituting back: , so .
The fact is an equality between shared constants. It is a note worth passing.
Σ_R derives x = y. → pass to Σ_=.
Step 2. The equality reasoner takes and adds the new fact . It already knew and . Function congruence, the rule that equal inputs go to equal outputs, gives , so .
Σ_= derives u = v. → pass to Σ_R.
Step 3. The real-arithmetic reasoner takes and adds . The constraint becomes . It already deduced earlier. So .
Σ_R derives w = z. → pass to Σ_=.
Step 4. The equality reasoner takes and adds . The standing constraint now contradicts function congruence: equal inputs give equal outputs , but the formula explicitly says .
Σ_= is unsatisfiable. → original formula is UNSAT.
Each step is a short proof. The whole chain is mechanically
verified by 03-no-trace.py, which runs each step as a Z3 query of
the form "does this conjunction with the negated conclusion
become unsatisfiable?"
== Nelson-Oppen trace ==
Sanity: each side is satisfiable in isolation.
✓ Σ_R alone is sat
✓ Σ_= alone is sat
Step 1: Σ_R derives x = y.
✓ Σ_R ⇒ x = y
→ pass to Σ_=.
Step 2: Σ_= ∧ x = y derives u = v.
✓ Σ_= ∧ x=y ⇒ u = v
→ pass to Σ_R.
Step 3: Σ_R ∧ u = v derives w = z.
✓ Σ_R ∧ u=v ⇒ w = z
→ pass to Σ_=.
Step 4: Σ_= with all propagated equalities is unsat.
✓ Σ_= ∧ x=y ∧ u=v ∧ w=z is unsat
⇒ The original formula is UNSAT.
Compare this to Act 2. Same pattern (two theories, mixed formula), very different result. Act 3's reasoners had clean, single equalities to pass back and forth. Act 2's integer reasoner only had a disjunction of equalities to offer, and neither equality propagation step ever fired. Same machinery, stuck on a different shape of fact. We will name that gap in Theory §9 and fix it.
Theories pass notes through equality
Three notes were passed across the boundary. Each note was an equality between shared constants. Each was verified by the sending reasoner as a logical consequence of its own theory plus whatever notes had already arrived. Neither reasoner ever read a literal in the other's signature.
This is the catchphrase for the rest of the lecture.
Theories pass notes through equality.
The shared constants are the mailbox. The equality symbol is the postal service: it is the only symbol both theories speak. Everything else stays in its own world.
After the break, Theory makes this precise. Why does the procedure terminate? When does it produce SAT, when UNSAT, and when do we need more than equality propagation? And what was the deal with that disjunction-of-equalities formula from Act 2?
Bridge to Theory
Three demos in. We have seen cooperation work in miniature (Act 1, two theories agreeing on a counterexample), seen the cooperation gap (Act 2, where simple equality propagation is not enough), and walked the cooperation algorithm step by step on Bradley and Manna's worked example (Act 3).
After the break, we open the box.
Source
The Act 2 cooperation-gap formula and the Act 3 worked example are from Aaron Bradley and Zohar Manna, The Calculus of Computation (Springer, 2007), Chapter 10. Act 2's formula is Example 10.1 (p. 270); Act 3's example and propagation chain are Example 10.13 and Figure 10.1 (p. 279–280). Act 1's arrays-as-functional-update model is from the same book's Chapter 11.