SMT stands for satisfiability modulo theories: the boolean skeleton is decided the same way Part 2 already does it, but individual boolean variables now stand for atoms in some theory - equalities, linear inequalities - rather than being meaningless propositional letters. The DPLL(T) architecture bolts a theory solver onto CDCL with almost no change to CDCL itself, which is the part worth seeing concretely.
Refactoring Part 2: one conflict handler
Part 2's cdcl special-cased boolean conflicts inline. Pulling that into its own function is what makes plugging in theory conflicts later a non-event - both kinds of conflict end up calling exactly this.
(* lib/dpll_t.ml *)open Sat_libopen Cdcl(* returns None on a level-0 conflict (UNSAT), Some () once thelearned clause is added, backjumped to, and its assertingliteral enqueued. *)let handle_conflict (s : solver) (conflict : clause) : unit option =if s.dlevel = 0 then Noneelse beginlet learnt, bt_level = analyze s conflict inlet learnt_clause = { lits = Array.of_list learnt; learnt = true } ins.clauses <- learnt_clause :: s.clauses;backtrack s bt_level;let asserting = List.hd learnt inenqueue s asserting (Some learnt_clause);Some ()end
The theory interface
An atom is whatever the theory reasons about - here, equalities and disequalities between term ids. check takes the atoms currently true under the boolean assignment and either confirms consistency or names a subset that cannot all hold together.
type term = inttype atom =| Eq of term * term| Neq of term * termmodule type THEORY = sigval check : atom list -> atom list option(* None = consistent. Some conflicting = this subset cannot all be true *)end
A real, if small, theory: equality via union-find
Path-compressed, union-by-rank union-find - nothing SMT-specific about it yet, just the standard structure.
(* lib/union_find.ml *)type t = { parent : int array; rank : int array }let create (n : int) : t ={ parent = Array.init n (fun i -> i); rank = Array.make n 0 }let rec find (uf : t) (x : int) : int =if uf.parent.(x) = x then xelse beginlet r = find uf uf.parent.(x) inuf.parent.(x) <- r;rendlet union (uf : t) (x : int) (y : int) : unit =let rx = find uf x and ry = find uf y inif rx <> ry thenif uf.rank.(rx) < uf.rank.(ry) then uf.parent.(rx) <- ryelse if uf.rank.(rx) > uf.rank.(ry) then uf.parent.(ry) <- rxelse beginuf.parent.(ry) <- rx;uf.rank.(rx) <- uf.rank.(rx) + 1end
check: union every Eq atom first, then look for a Neq whose two sides ended up in the same class - a direct contradiction. num_terms is threaded through since union-find needs to know its universe size up front.
(* lib/eq_theory.ml *)open Union_findlet check (num_terms : int) (atoms : atom list) : atom list option =let uf = create num_terms inlet eqs = List.filter_map (function Eq (a, b) -> Some (a, b) | Neq _ -> None) atoms inList.iter (fun (a, b) -> union uf a b) eqs;let violated =List.find_opt(function Neq (a, b) -> find uf a = find uf b | Eq _ -> false)atomsinmatch violated with| None -> None| Some bad_neq ->Some (List.filter (function Eq _ -> true | Neq _ -> false) atoms @ [ bad_neq ])
The conflict returned here is every currently-true equality plus the violated disequality - sound (that exact combination really is contradictory), but not minimal: a real congruence closure tracks which specific chain of equalities merged the two terms and returns only that chain. Returning everything is honest scope-cutting, not a bug - the SAT layer above still learns a correct clause from it, just a less general one than the best possible explanation would give.
Transitivity failing directly: x=y, y=z, x<>z can never all hold. check catches it because union merges x, y, and z into one class before the Neq is examined.
# check 3 [ Eq (0, 1); Eq (1, 2); Neq (0, 2) ];;- : atom list option =Some [Eq (0, 1); Eq (1, 2); Neq (0, 2)]# check 3 [ Eq (0, 1); Neq (1, 2) ];;- : atom list option = None (* consistent: 0 and 2 were never forced equal *)
Wiring atoms to boolean variables
Every distinct atom in the problem gets exactly one fresh boolean variable - a bidirectional table is all that connects the two layers.
(* lib/atomize.ml *)open Dpll_ttype atomization = {atom_of_var : (int, atom) Hashtbl.t;var_of_atom : (atom, int) Hashtbl.t;num_terms : int;}let make (num_terms : int) (atoms : atom list) : atomization =let atom_of_var = Hashtbl.create 64 inlet var_of_atom = Hashtbl.create 64 inList.iteri(fun i atom ->let v = i + 1 inHashtbl.replace atom_of_var v atom;Hashtbl.replace var_of_atom atom v)(List.sort_uniq compare atoms);{ atom_of_var; var_of_atom; num_terms }
Reading off the currently-true atoms from a fully assigned boolean state - exactly the variables whose value is true, mapped back through the table.
let true_atoms (s : Cdcl.solver) (az : atomization) : atom list =List.filter_map(fun v ->if s.Cdcl.value.(v) = Some true then Hashtbl.find_opt az.atom_of_var velse None)(List.init s.Cdcl.n (fun i -> i + 1))
Turning a theory-conflicting subset of atoms into a blocking clause - every one of them was true, so the clause forbidding that exact combination is the disjunction of their negations.
let theory_clause (az : atomization) (conflicting : atom list) : Cdcl.clause =let lits =List.map (fun atom -> - (Hashtbl.find az.var_of_atom atom)) conflictingin{ Cdcl.lits = Array.of_list lits; learnt = true }
The DPLL(T) loop
Identical to Part 2's cdcl except for one new case: when propagation finds no boolean conflict and every variable is assigned, ask the theory before declaring victory. A theory conflict is handled by the exact same handle_conflict as a boolean one - that reuse is the entire point of the architecture.
(* lib/dpll_t.ml, continued *)let rec solve_loop (s : Cdcl.solver) (az : atomization) : bool option array option =match Cdcl.propagate s with| Some conflict ->(match Dpll_t.handle_conflict s conflict with| None -> None| Some () -> solve_loop s az)| None ->(match Cdcl.pick_branch s with| Some v ->s.Cdcl.dlevel <- s.Cdcl.dlevel + 1;Cdcl.enqueue s v None;solve_loop s az| None ->let atoms_now = true_atoms s az in(match Eq_theory.check az.num_terms atoms_now with| None -> Some (Array.copy s.Cdcl.value) (* boolean-complete and theory-consistent *)| Some conflicting ->let clause = theory_clause az conflicting ins.Cdcl.clauses <- clause :: s.Cdcl.clauses;(match Dpll_t.handle_conflict s clause with| None -> None| Some () -> solve_loop s az)))
A subtlety worth naming: theory_clause always produces a clause that is currently falsified (every literal negates something that was just true), which is exactly what handle_conflict expects to analyze - a theory conflict and a boolean conflict are, from that function's point of view, indistinguishable.
(* handle_conflict does not know or care whether `conflict` came fromCdcl.propagate finding a falsified clause, or from a theory sayinga set of atoms cannot hold - both are just `clause` values that arecurrently entirely false. *)
A worked example: boolean-SAT but theory-UNSAT
Three atoms, three terms, and a boolean formula that forces all three atoms true with no boolean conflict whatsoever - the plain SAT layer alone would happily call this satisfiable.
let () =let atoms = [ Eq (0, 1); Eq (1, 2); Neq (0, 2) ] inlet az = Atomize.make 3 atoms inlet v_eq01 = Hashtbl.find az.var_of_atom (Eq (0, 1)) inlet v_eq12 = Hashtbl.find az.var_of_atom (Eq (1, 2)) inlet v_neq02 = Hashtbl.find az.var_of_atom (Neq (0, 2)) inlet f = [ [ v_eq01 ]; [ v_eq12 ]; [ v_neq02 ] ] inlet s = Cdcl.make_solver 3 f inmatch Dpll_t.solve_loop s az with| None -> print_endline "UNSAT"| Some _ -> print_endline "SAT"
What it prints - correctly UNSAT, even though every clause is a single positive unit literal and Part 2's plain CDCL over the same three clauses would return SAT immediately after one round of unit propagation.
UNSAT
Replacing the third clause with a disjunction gives the solver a real choice - now it is genuinely satisfiable, by picking the disequality's other option instead of forcing all three atoms true.
let f = [ [ v_eq01 ]; [ v_eq12 ]; [ v_neq02; - v_eq01 ] ] in(* satisfied either by taking the disequality (theory-consistent only if0 and 2 are not forced equal - they still would be here via eq01/eq12,so the solver instead has to fall back on the other disjunct: -eq01) *)
Tracing what happens: the boolean layer first tries eq01=true, eq12=true (both forced units), which makes the third clause need v_neq02; asserting that triggers the same transitivity conflict as before, handle_conflict learns a clause, backjumps, and this time the solver is forced to flip eq01 to false instead - genuinely different boolean search driven entirely by a theory conflict.
SAT(* model: eq01=false, eq12=true, neq02=true - consistent, since 0 and 2 arenever forced into the same equivalence class *)
What Part 4 plugs into this same interface
Nothing above the THEORY module signature and the check function inside it is specific to equality - solve_loop, handle_conflict, atomize, and theory_clause are all already generic. Part 4 swaps Eq_theory.check for a linear-arithmetic theory over the identical plumbing, with the same guarantee: any theory-level contradiction becomes a learned clause the boolean search can backjump on.
module type THEORY = sigval check : atom list -> atom list optionend(* Part 4: Eq_theory.check is replaced by Diff_logic.check, decidingatoms of the shape `x - y <= k` via a negative-cycle check insteadof union-find - solve_loop itself does not change at all. *)