might not be the best use of the programmer’s time. An application which shows this even clearer is a SAT-solver for propositional logic. More and more programs rely on SAT-solvers, and the solvers themselves are complicated pieces of software, so constructing a formal proof that they return the correct answer is valuable. On the other hand, showing that the solver always terminate is both very subtle (to prove that a clause-learning solver terminates, one must prove that the clauses it learns eventually rule out all possible assignments), and uninteresting in practice (typically it will run out of memory long before it terminates).
Most modern SAT-solvers use the clause-learning algorithm pioneered in zChaff [148]. A realistic solver is too big to be written as part of this thesis, but we can illustrate the interplay between termination-checked and nonterminating code by verifying a simple Davis-Putnam-Logemann-Loveland (DPLL) solver. The DPLL algorithm is an immediate ancestor of the clause-learning algorithm, so it is a good illustration of how to structure such proofs.
Recall that a boolean formula is a conjunction ofclauses, each clause is a disjunction of literals, and a literal is either a plain or a negated variable. The SAT problem is to either find an assigment from variables to booleans that makes the formula true, or prove that there exists no such assignment.
In our implementation we represent variables as bounded natural numbers (Fin), literals as pairs (Times) of a variable and a boolean, and assignments as vectors of boolean. Formulas are represented as lists of lists of literals, and the interpfunction evaluates a formula under a given assignment.4 We take advantage of dependent
types by indexing the formula by the number of variables, so the typechecker can check that the vector access is in bounds.
log Formula : (n:Nat) → Type
Formula = λn. List (List (Times (Fin n) Bool))
log interp_lit : [n:Nat] ⇒ Vector Bool n
→ (Times (Fin n) Bool @log) → Bool interp_lit [n] assign lit = case lit of
Prod i b → bool_eq b (lookup i assign)
log interp_clause : [n:Nat] ⇒ Vector Bool n →
(List (Times (Fin n) Bool) @log) → Bool
4For reasons explained in Section 7.7.3, the functions interp_lit and interp_clause have a
spurious@login their types. This qualifier makes no difference semantically here, but is needed due to a limitation of type inference.
interp_clause [n] env clause = any (interp_lit env) clause
log interp : [n:Nat] ⇒ Vector Bool n → Formula n → Bool
interp = λ [n] env .
unfold (Formula n) in all (interp_clause env)
While searching for a solution, the solver manipulatespartial assigments, which spec- ify the values of only some of the variables. In our program we represent these as Vector (Maybe Bool) n. It easy to treat a partial assignment as an assigment, by picking some arbitrary value (True, say) for the missing variables. In order to state the invariants of the algorithm, we say an assignment φ extends a partial assignment
ψ if it agrees whereeverψ is defined.
log extend : [n:Nat] ⇒ Vector (Maybe Bool) n → Vector Bool n extend = λ[n] xs . vmap (maybe True) [n] xs
log Extends : (n:Nat) ⇒ Vector (Maybe Bool) n → Vector Bool n → Type
Extends n psi phi =
(i : Fin n) → (b:Bool)
→ (lookup i psi = (Just b))
→ (lookup i phi = b)
Now we can state the specification of the solver. When given an argumentpartialit should return either an assignment together with a proof that the formula evaluates to true (Sat n formula), or a proof that there is no way to extend partial into a satisfying assignment (Unsat n formula partial). At the beginning of the run we will ask for a solution extending the empty assignment, i.e. for any solution at all. data Sat (n:Nat) (formula : Formula n) : Type where
SAT of (partial : Vector (Maybe Bool) n)
[_ : interp (extend partial) formula = True ] data Unsat (n:Nat) (formula : Formula n)
(phi : Vector (Maybe Bool) n) : Type where UNSAT of [_ : (phi’ : Vector Bool n)
→ Extends phi phi’
→ (interp phi’ formula = False )]
So how do we find the solution? Davis, Putnam, Logemann, and Loveland [42, 43] proposed two rules to use:
Unit propagation If there is some clause which is currently unsatisfied and which only contains one unassigned variable, set that variable to make the clause true. This rule corresponds to inference using implications. For example, an implication (p∧q =⇒ r) is encoded as the clause (¬p∨ ¬q∨r). So if at some
point we learn that p and q are true, then r is the only remaining unassigned variable in the clause and we can set r to true as well.
Branch Otherwise, pick some currently unassigned variable and guess its value. If the search later fails, we backtrack and try the other value instead.
(The original presentation also included a third rule, pure literal elimination. However, this is expensive to implement, so modern solvers usually omit it, and we do not include it in our program.)
We use two helper functions that implement these rules, which each take a formula and a partial assignment as inputs. The function setunits finds all unit clauses in the formula, and returns a new assignment with the corresponding variables set. The function partial_interp either notes that the formula is alread satisfied, or that all variables are assigned and it is unsatisfied, or it picks some currently unassigned variable. Its return type isOr (Sat n f) (Or (Unsat n f partial) (Fin n)). With these two helper function, we can implement the solver itself:
prog dpll : [n:Nat]
⇒ (formula : Formula n)
→ (partial : Vector (Maybe Bool) n)
→ (Or (Sat n formula) (Unsat n formula partial)) rec dpll [n] = λ formula partial .
let upartial = setunits formula partial in
case (partial_interp upartial formula) [s_eq] of
InL sat → InL sat
InR (InL unsat) → InR (unsat_units formula partial unsat) InR (InR i) →
case (dpll formula
(set i (Just True) upartial)) of
InL sat → InL sat
InR unsat1 →
case (dpll formula
(set i (Just False) upartial)) of
InL sat → InL sat
InR unsat2 →
InR (unsat_units formula partial
(unsat_branch i formula upartial unsat1 unsat2))
Most of the function directly implements the backtracking search. Because the func- tion is in prog this can be done using general recursion, just as in any other pro- gramming language. The extra work to verify correctness shows up at the end of the function, in the call to InR. This is when the search failed and we must return a
proof that the formula is unsatisfiable. From the recursive calls we have available two proofsunsat1 and unsat2, stating that there are no solutions after unit-propagating and guessing either True or False for the variable i. So to complete the proof we need two correctness lemmas about unsatisfiablility:
log unsat_units : [n:Nat] ⇒ (f : Formula n)
→ (p : Vector (Maybe Bool) n)
→ Unsat n f (setunits f p)
→ Unsat n f p
log unsat_branch : [n:Nat] ⇒ (i : Fin n) → (f : Formula n)
→ (phi : Vector (Maybe Bool) n)
→ Unsat n f (set i (Just True) phi)
→ Unsat n f (set i (Just False) phi)
→ Unsat n f phi
A real SAT solver would be more complicated in various ways. It needs to use more efficient data structures in memory. It needs to pick the branch variable intelligently (partial_interp just picks the first unassigned variable), and use a better heuristic of how far to backtrack. It should use the efficient “two watched variables” scheme to find unit clauses (setunits scans through all the clauses each time). But note that none of these affect the correctness statement that we are proving, so they can all be done in prog. Finally, it should use clause learning to record more information when it backtracks. That would require an additional soundness lemma stating that the new clause is implied by the existing ones.
The function dpll illustrates many of the ideas that we discussed earlier in this chapter. First, it is an example of a program mostly defined by general recursion, without worrying about termination.
Second, it uses both internal and external reasoning. For the functions dpll and partial_interpit is very convenient to use the internal style (which is well suited to these kind of partial correctness statements for potentially nonterminating functions). But we also found that some lemmas, like unsat_units, are more natural to state and prove separately from the function they talk about.
Finally,dpllmakes crucial use oferasure. The implementation of the function chains together many applications of the soundness lemmas to eventually build a proof of unsatisfiability. It would be very slow if those functions had to be actually invoked at runtime. But the proof arguments of SAT and UNSAT are marked erasable (square brackets). So at runtime, the Unsattype is isomorphic to a unit type, and the return type of dpll, (Or (Sat n formula) (Unsat n formula partial)) is isomorphic toMaybe (Vector (Maybe Bool) n). The reasoning about correctness is done com- pletely statically, during the type checking.