Ask, Observe, Refine
Your first session writing Z3 code yourself. The exercises build one skill: asking the solver a question, reading the answer, and refining the question based on what you saw.
Practice showed you what theories buy you. Theory walked you through congruence closure on paper and in Python. Studio is where you type the code yourself.
Three files, increasing in depth. Open each in your editor and work through it in order. Staff are circulating. Predict before you run. Read the output carefully when you get it.
The files live in the course code repo. Each has a -soln.py companion next to it: try each exercise yourself first, peek only if stuck.
Three parts, one skill
- Part 1: Exploring sudoku with the solver. Reuse the sudoku-smt encoding from Practice. Count all solutions with a blocking-clause loop. Watch the count grow when you remove givens. Use the solver to rebuild uniqueness on an under-determined puzzle.
- Part 2: Congruence playground. Six short EUF formulas. Predict sat or unsat for each, fill in the Z3 assertions, run, and compare. By the end, writing
DeclareSort,Function,Consts,s.add,s.check,m[x],m[f]should feel routine. - Part 3: EUF models and refinement. Take a sat EUF formula and repeatedly refine it based on the witness Z3 just built. Part 1's iterative-dialogue skill applied on top of Part 2's EUF fluency.
Part 1 refines on sudoku, where the refinement is a blocking clause. Part 3 refines on EUF, where the refinement is a new disequality on a fresh constant. Different theories, different mechanics, same rhythm: ask, observe, refine, ask again.
Plan to get through Parts 1 and 2. Part 3 is stretch territory.
Part 1: Exploring sudoku with the solver
File: 01-sudoku-explore.py.
In Practice you saw sudoku solved two ways, once with booleans and once with integers. The SMT encoding was short: 81 integer variables and Distinct() for the row, column, and block rules. Part 1 starts from that encoding.
Exercise 1. Count all solutions
The Practice demo called s.check() once and stopped. To find every solution, loop: each time Z3 returns a model, add a blocking clause that rules out exactly that assignment, then call check() again. When the loop returns unsat, you have enumerated the whole solution space.
Solution
The blocking clause has a short form. For each cell, build an inequality between the variable and its value in the current model, then Or() them. The m[cells[r][c]].as_long() idiom pulls a cell's value out of the model as a plain Python integer.
blocking = Or([
cells[r][c] != m[cells[r][c]].as_long()
for r in range(size) for c in range(size)
])
Run the loop on the Practice puzzle. A well-formed sudoku has exactly one solution, and your count should confirm it.
Exercise 2. Count with fewer givens
The file has an UNDER_DETERMINED puzzle: the Practice puzzle with three givens removed. Count its solutions. You should get a small number greater than one.
Experiment. Remove or restore givens in the string, re-run, observe. How few givens can you have before the count gets huge?
One aside about encoding correctness. Instead of editing the data, try commenting out one of the Distinct() calls inside make_solver and re-run the count. The count explodes much more dramatically. The Distinct() constraint was doing real work, even though "all different" might feel like it follows automatically from the other rules. Removing a redundant-looking constraint can silently give you a correct answer to the wrong question.
Exercise 3. Restore uniqueness
Given an under-determined puzzle, design an algorithm that adds givens one at a time until the puzzle has exactly one solution.
A greedy approach works. While the puzzle has more than one solution, enumerate two of them, find a blank cell where they disagree, and pin it to the value from one of them. Repeat until the count is one.
Your code is the outer loop. The solver is the inner primitive: given a puzzle, it tells you whether another solution exists. Your loop picks the next cell to pin based on that answer.
The algorithm is greedy and finds a unique-ifying set, not the smallest one. Finding the smallest is harder.
Stretch: deducibility
A well-formed sudoku has exactly one solution. But solution count alone misses something about puzzle quality. A good puzzle is one where a human solver can always deduce one more cell from what they already know, without having to guess.
Formally: cell is forced to value when puzzle ∧ c ≠ v is unsatisfiable. No consistent assignment puts a different value there, so must be . A puzzle is deducibly solvable if, starting from the givens, you can always find at least one forced cell, fill it in, and continue until the board is full.
Write a function that takes a board and returns a forced cell, or reports that none exists. Wrap it in an outer loop that fills the board one forced cell at a time. Try it on the Practice puzzle. Then try it on one of your restore_uniqueness outputs. Does greedy-uniqueness always produce deducibly-solvable puzzles?
No solution file. The exercise is open-ended on purpose.
Part 2: Congruence playground
File: 02-congruence-playground.py.
In Theory you traced congruence closure by hand and watched a Python implementation run. Part 2 has six short EUF formulas. For each, predict sat or unsat, fill in the Z3 assertions, run, and compare.
Commit to the prediction before you run. Backfilling the prediction after reading the output skips the exercise.
The setup block at the top of the file declares a single uninterpreted sort S, a unary function f, and constants a, b, x, y. Every exercise builds on these.
Exercise 1 (warm-up, worked)
. Unsat. If and are in the same class, function congruence forces and into the same class too, and the disequality contradicts. The code is given; run it to see the mechanics of Solver / add / check on EUF.
Exercise 2. Does a 2-cycle exist?
. Can there be a function and an element such that applying twice brings you back, but applying it once does not? Think about it concretely. Predict, then check.
Answer
sat
Exercise 3. Theory's worked example
. You traced this by hand in Theory. Now type it in Z3 and run it.
Answer
unsat
Exercise 4. Congruence goes forward, not backward
. Congruence says equal inputs give equal outputs. It does not say equal outputs force equal inputs. Hash collisions are the everyday example: two different strings can hash to the same value.
Exercise 4 is also where you practice model extraction. Once Z3 returns its answer, call s.model() and print m[x], m[y], m.eval(f(x)), m.eval(f(y)), and m[f]. The values for uninterpreted sort elements print as S!val!N: opaque handles, where different handles mean different values.
Answer
sat. The last line of the model is the most interesting: m[f] shows the whole function Z3 built. Z3 usually picks the simplest witness that satisfies the constraints, so on this formula it picks a constant function.
Exercise 5. Combining cycles
. Exercise 2 showed that a 2-cycle exists. What if you also require a 3-cycle from the same ? Think about what would have to do. Can both cycles hold while still survives?
Answer
unsat
Stretch: the symmetry gotcha
. At first glance the first literal "says" is symmetric, so swapping the arguments should not change the result, which should force the second literal to be unsat. Predict before you peek.
Answer and explanation
sat. The first literal does not make g symmetric in general. It says two specific terms, g(x, y) and g(y, x), are in the same equivalence class. Congruence only propagates when the arguments themselves land in the same class. Here, a and x are in different classes, so congruence gives nothing about g(a, y) versus g(y, a).
Asserted equalities are literals, not axioms. A universal claim about g ("for all u, v, g(u, v) = g(v, u)") would be a different beast. It would live in full first-order logic, which is Lecture 5 material.
Part 3: EUF models and refinement
File: 03-euf-models.py.
Part 3 is stretch territory. If you reach it, Part 1's iterative-dialogue skill gets applied to EUF.
The base formula is . Three distinct elements that all collide under . The same spirit as Exercise 4 in Part 2, now with one more collision.
Exercise 1 (worked). Baseline witness
Run the formula and print m[x], m[y], m[z], m[f]. Observe that Z3 picked the simplest possible : a constant function mapping everything to one output. The three disequalities are satisfied because Z3 gave , , three distinct opaque values, and the constant satisfies the equalities trivially.
Exercise 2. Force f to be non-constant
Introduce a fresh constant c1 and add the constraint . Re-check. Predict what the witness looks like before you read the output.
What should you see?
No constant function can satisfy the new formula, so Z3 must pick an f that takes at least two values. The new m[f] has one special case plus the else default. The lookup table grew by one row.
Exercise 3. Force a third distinct output
Introduce c2 and add two constraints: and . The second disequality matters. Without it, Z3 could set and the lookup table would not grow. Predict again before you read the output.
What should you see?
The new m[f] has two special cases plus the else default: three distinct outputs total. The pattern scales: each refinement round adds one more row.
Stretch: your own refinement
Write a refinement of your own. Predict what the witness will look like, then run and compare. Some directions:
- Force a fourth distinct output by introducing
c3with three disequalities. - Force two of the existing constants to map to the same value instead of different ones.
- Force to treat
c1andc2identically.
Any refinement that produces a structurally different witness is a valid answer. There is no single right move.
The closing thought
Iterative refinement is the same skill whether you enumerate sudoku solutions or shape a witness for an EUF formula. The move is: ask, read the answer, build a new constraint from what you saw, ask again.
One limit. Every witness Z3 gives you has a finite representation: a lookup table with an else default. You can keep refining to make that table larger, but you cannot force to be injective in quantifier-free EUF. Injectivity is a universal statement, and universal statements push you out of the decidable fragment. That is Lecture 5 material.
What you should be able to do now
By the end of Studio, typing Z3 code should feel routine rather than precarious. Specifically, you should be able to:
- Declare integer or uninterpreted-sort variables and add constraints on them.
- Call
s.check(), branch on the result, and extract values froms.model(). - Write a blocking clause that rules out the current model and loop until
unsat. - Encode a formula from the Theory page into Z3 assertions.
- Use the solver as a primitive inside your own loop, not just as a one-shot oracle.
If some of these feel shaky, that is fine. Coding Assignment 2 is extra practice on the same skills, and the patterns reappear in Lecture 4 and Lecture 5.
One thing Studio did not cover
Axiom engineering, the skill you watched in Practice with umul in the sq/sqabs demo, is not something you typed yourself in Studio. Practice covers the full three-attempt walkthrough, and a Studio exercise on top would duplicate a lesson that already landed.
When you come back to these materials to prepare for Coding Assignment 2, re-read the Abstraction for Performance section in Practice. The move is to replace an interpreted function with an uninterpreted one and add only the axioms your proof depends on. Coding Assignment 2 gives you hands-on practice with it.
Coming up
Next week's Lecture 4 surveys the other theory solvers: linear real and integer arithmetic, bitvectors, arrays. Each has its own decision procedure, and each follows the same "give me a conjunction of literals in my theory, tell me sat or unsat" contract we sketched at the end of Practice.
Lecture 5 shows how the theory solvers and CDCL cooperate as DPLL(T), the architecture of modern SMT. That is also where we cover full first-order logic and see what the price is for adding quantifiers.
Demo code
All three Studio files are in the course code repo next to the Practice and Theory demos. Clone and run them locally with python3 01-sudoku-explore.py etc. (requires z3-solver; see Setup).