Introduction to Automated Theorem Proving with Logtk
My PhD work is centered around automated theorem proving in first-order logic. This is obviously a very cool topic (otherwise I wouldn't have focused on it), so this post is a crash course (but the program won't crash because I use OCaml
) on one of the most classic method to prove (some) theorems automatically. I named... resolution!
The goal is to prove some (not too complicated) theorems automatically. In other words, we want a program that reads a bunch of axioms and a formula that we conjectured is a theorem following from the axioms, and then tries to produce a proof of the theorem. In practice it's almost always done by refutation: to prove that Γ ⊢ F (formula F is a theorem under axioms Γ), we try to deduce ⊥ (false) from Γ, ¬F). The applications for this kind of technology are multiple, but afaik the prominent one is software verification — aims at formally proving that a program satisfies a specification ("not crashing" is a good start). There has been a lot of research in this area for decades, but the problem is extremely hard (only semi-decidable: you will find a solution eventually if the problem is a theorem, but might run forever otherwise).
In this post I will mostly present the code for a very simple (and naive, too) theorem prover in OCaml, using my library Logtk. I assume the reader has some basic knowledge of first-order logic (quantifiers ∀ and ∃, logic connectives ¬, ∨, ∧ and ⇒, and the notion of term). The code is available in Logtk itself (raw version). If you installed the most recent version (0.5.1) of Logtk
, it should compile using
$ ocamlbuild -use-ocamlfind -package logtk,logtk.parsers \
resolution1.native
You can test it on easy problems defined in the TPTP syntax, for instance some of the Pelletier Problems (some of which are too hard for the prover!). TPTP is also an archive with literally thousands of problems (from easy to very hard) in the common syntax described above.
Preamble
Rename a few modules for convenience. CCError
comes from ocaml-containers.
module Err = CCError
module T = Logtk.FOTerm
module F = Logtk.Formula.FO
module Substs = Logtk.Substs
module Unif = Logtk.Unif
module Util = Logtk.Util
Basic Blocks
We start with basic building blocks that are mostly provided by Logtk
. Resolution is a clausal calculus, that is, it deals with first-order clauses. A clause is a disjunction of literals (atomic formulas or negated atomic formulas). Let's see.
First, we define a global signature (maps symbols such as f, parent_of or greater to their type). Every symbol has exactly one type. The initial signature is the TPTP
signature (logic connectives)
let _signature = ref Logtk.Signature.TPTP.base
We do not have to do anything about terms, because they are already defined in Logtk.FOTerm
(which was renamed T
above). Terms are either variables or applications of a constant (symbol) to a list of sub-terms.
Some examples of terms would be (capitalized letter are variables):
Y
(a variable)the_universe
(a constant)f(X, g(X,a))
(function applications)age_of(grandmother_of(frida))
Literals
Then we have to represent literals, because Logtk
doesn't (the representation would be too specific). A literal is an atomic proposition (term of type $o
in TPTP
, i.e. the type of propositions), or its negation. We represent this as a pair of type FOTerm.t * bool
(term + sign).
Examples:
older_than(obama, bieber)
¬ lives_in(paris, poutine)
module Lit = struct
type t = T.t * bool
(** We also define a few basic comparison and printing functions.
Comparison functions are used by many data structures;
Printing is useful for informing the user of results or
for debugging. *)
let compare = CCOrd.pair T.cmp CCOrd.bool_
let equal a b = (compare a b) = 0
let pp buf (t,b) =
Printf.bprintf buf "%s%a"
(if b then "" else "¬") T.pp t
end
Clauses
A clause is a disjunction ("or") of literals. We will simply use a list of literals.
Examples:
¬ lives_in(paris, X) ∨ eats_baguette(X)
(means "forall X, if X lives in Paris then X eats baguette")greater_than(successor(X), X)
(property on integers)
The whole Peano arithmetic (excluding induction which is not first-order logic) would look like:
nat(0)
X = X
¬ (X = Y) ∨ Y = X
¬ (X = Y) ∨ ¬ (Y = Z) ∨ (X = Z)
¬ nat(X) ∨ ¬ (X = Y) ∨ nat(Y)
nat(succ(N))
¬ (succ(N) = 0)
¬ (succ(M) = succ(N)) ∨ (M = N)
module Clause = struct
type t = Lit.t list
let make l = CCList.Set.uniq ~eq:Lit.equal l
let compare = CCOrd.list_ Lit.compare
let equal a b = compare a b = 0
let is_trivial c =
List.exists
(fun (t,b) ->
b &&
List.exists (fun (t',b') -> not b' && T.eq t t') c
) c
let apply_subst ~renaming subst c s_c =
let c = List.map
(fun (t,b) -> Substs.FO.apply ~renaming subst t s_c, b)
c
in make c
(** printing a clause: print literals separated with "|" *)
let pp buf c = CCList.pp ~sep:" | " Lit.pp buf c
(** Conversion from list of atomic formulas.
type: [Formula.t list -> clause] *)
let _of_forms c =
let _atom f = match F.view f with
| F.Not f' ->
begin match F.view f' with
| F.Atom t -> t,false
| _ -> failwith "unsupported formula"
end
| F.Atom t -> t, true
| _ -> failwith "unsupported formula"
in
make (List.map _atom c)
end
Some parts of this module introduce new concepts. First, triviality, then, substitutions.
- A clause is trivial if it contains both a literal and its opposite. It means the clause is tautological, that is, always true; we can dispose of it because resolution is about refutation (deduce ⊥ from hypothesis). The function
Clause.is_trivial
checks whether this simple criterion holds. - A substitution maps some variables to terms. Here the function
Clause.apply_subst
will be used to apply the substitution to a clause — replace variables of the clause by their image in the substitution (or keep them unchanged if they do not appear in the substitution. Substitutions are pre-defined in Logtk, and applying a substitution to a term is defined too (the functionSubst.FO.apply
that applies a substitution to a first-order term)
Managing the Proof State
We have defined basic types, so we are ready to deal with more serious problems. The resolution calculus is based on saturation. It means that, given some inference rules, that deduce clauses from other clauses (deduction), we compute the least fix point of a set S of clauses with respect to those rules.
In other words, every time we can deduce a new clause C using inferences on the set S, we add C to S. The process stops when we find the empty clause (equivalent to ⊥, or "false") or when a fixpoint is reached (every clause we deduce is already in the set S).
In practice, we use the so-called "given clause algorithm". The proof state is composed of two disjoint sets:
- the active set contains clauses that have been processed (they are "active clauses"). It means we already made all possible inferences between the active clauses.
- the passive set contains clauses that have not been processed yet. Initially it contains all the input clauses (those from the problem to solve).
The main loop will transfer clauses from the passive set, to the active set, one-by-one. The current clause is called "given clause" (hence the name).
Utils
We need a few more types and modules to deal with the sets of clauses:
- A type
Clause.t * int
is used to refer to a specific literal within a specific clause. We will see why later. See the moduleClauseWithPos
. - A term index is used to query those literals by their term. Indexing is a crucial part of any real theorem prover. An index is basically a multimap from
FOTerm.t
toClause.t * int
. When we process a clause c, for each literal(term,sign)
at position i in the clause c, we add the binding term → (c, i) into the index. Later we will be able to retrieve the pair (c,i) using any term that unifies with term.
module ClauseWithPos = struct
type t = Clause.t * int
let compare = CCOrd.pair Clause.compare CCInt.compare
end
module Index = Logtk.NPDtree.MakeTerm(ClauseWithPos)
(** Set of clauses. Easy to define thanks to {!Clause.compare} *)
module ClauseSet = Set.Make(Clause)
Sets of Clauses
- We keep an index,
_idx
, over every atomic term in the set of active clauses; - We also keep the set of those clauses to be able to check whether a new clause is already processed or not;
- Last, a queue is used for passive clauses.
The exception Unsat
is used for early exit, in case the empty clause is found.
let _idx = ref (Index.empty())
let _active_set = ref ClauseSet.empty
let _passive_set = Queue.create()
exception Unsat
(** add [c] to the passive set, if not already present in
the active set nor it is trivial. *)
let _add_passive c =
if c = [] then raise Unsat
else if Clause.is_trivial c
then (
Util.debug 4 "clause %a is trivial" Clause.pp c;
)
else if not (ClauseSet.mem c !_active_set)
then (
Util.debug 4 "new passive clause %a" Clause.pp c;
Queue.push c _passive_set
)
(** When we process a clause [c], we put it into the
active set (set of processed clauses). That also
means every literal [(term,sign)]
at index [i] will go into the index, so we can
retrieve [c] by its literals later.
*)
let _add_active c =
_active_set := ClauseSet.add c !_active_set;
List.iteri
(fun i (t,_) -> _idx := Index.add !_idx t (c,i))
c
The Resolution Calculus
Inference rules: Explanations
Here we are at long last! Resolution, a very old calculus (back to the sixties, when Robinson invented it), only requires two inference rules to be complete (i.e., be able to eventually prove any theorem). Those rules are factoring and resolution.
The factoring rule looks like:
A ∨ A' ∨ C
---------------
σ (A' ∨ C)
if σ(A) = σ(A')
It means means that if the clause has two positive literals A
and A'
with some substitution σ, such that σ(A) = σ (A'), then we can factor those literals into σ(A) provided we also apply σ to the rest of the clause. This kind of rule reads from top (premises) to bottom (conclusion).
The resolution rule between two clauses a ∨ C and ¬ a' ∨ D, where a and a' are literals and C, D clauses, is
A ∨ C ¬A' ∨ D
------------------
σ(C ∨ D)
if σ(A) = σ(A')
This rule "resolves" together two complementary literals in two clauses (assuming those clauses do not share variables).
Let us explain in the propositional case (ignoring variables), assuming (a = a'). The idea is, roughly:
- We know that either a or either ¬ a is true (excluded middle)
- If a is true, it means that (¬a' ∨ D) can only be true if D is true (since a = a' = ⊤). Therefore D must be true.
- If a is false, then (a ∨ C) can only be true if C is true; therefore C holds.
- By excluded middle one of those must be true, so in any case C ∨ D is true. Hence the conclusion.
For the first-order case, we compute the most general unifier of a and a' (if it exists), and call this unifier substitution σ. Then, the reasoning is the same as in the propositional case since the literals are actually equal.
Note: the 0 and 1 are scopes, a trick I use to avoid actually renaming variables in one of the clauses. More details can be found in the documentation for Substs
or in the talk I gave at PAAR 2014.
Inference Rules: implementation
The corresponding code:
let _factoring c =
List.iteri
(fun i (t,b) ->
if b then List.iteri
(fun j (t',b') ->
(** Only try the inference if the two literals have
positive sign. The restriction [i < j] is used
not to do the same inference twice (symmetry).
*)
if i<j && b'
then try
let subst = Unif.FO.unification t 0 t' 0 in
(** Now we have subst(t)=subst(t'),
the inference can proceed *)
let c' = CCList.Idx.remove c i in
let renaming = Substs.Renaming.create() in
(** Build the conclusion of the inference (removing
one of the factored literals *)
let c' = Clause.apply_subst ~renaming subst c' 0 in
Util.debug 3 "factoring of %a ----> %a"
Clause.pp c Clause.pp c';
(** New clauses go into the passive set *)
_add_passive c'
with Unif.Fail -> ()
) c
) c
let _resolve_with c =
List.iteri
(fun i (t,b) ->
(** Retrieve within the index, mappings
[term -> (clause,index)]
such that [term] unifies with [t].
0 and 1 are again scopes. *)
Index.retrieve_unifiables !_idx 0 t 1 ()
(fun () _t' (d,j) subst ->
let (_,b') = List.nth d j in
(** We have found [_t'], and a pair [(d, j)] such
that [d] is another clause, and the
[j]-th literal of [d] is [_t', b']).
If [b] and [b'] are complementary we are in
the case where resolution applies.
*)
if b<>b'
then (
let renaming = Substs.Renaming.create() in
(** Build the conclusion clause, merging the
remainders [c'] and [d'] (which live respectively
in scope 1 and 0) of the clauses together after
applying the substitution. *)
let concl =
(let c' = CCList.Idx.remove c i in
Clause.apply_subst ~renaming subst c' 1)
@
(let d' = CCList.Idx.remove d j in
Clause.apply_subst ~renaming subst d' 0)
in
(** Simplify the resulting clause (remove duplicate
literals) and add it into the passive set,
to be processed later *)
let concl = Clause.make concl in
Util.debug 3 "resolution of %a and %a ---> %a"
Clause.pp c Clause.pp d Clause.pp concl;
_add_passive concl
)
)
) c
Saturation Loop
Main saturation algorithm, a simple "given clause" loop. This is the outer loop of the resolution procedure: given an initial set of clauses S, the algorithm does:
- add all the clauses into the passive set
- while some passive clauses remain unprocessed, pick one of them, call it C, and then do the following:
- add C into the active set
- perform inferences between C and the active set (including C itself)
- add the resulting new clauses to S.
- if at any point the empty clause ⊥ is found, then the initial set of clauses is unsatisfiable (absurd).
- otherwise, if the loop stops, we have computed a fixpoint of the initial clauses with respect to inferences without finding ⊥, which means the original set of clauses is satisfiable (admits a model)
let _saturate clauses =
List.iter _add_passive clauses;
try
while not (Queue.is_empty _passive_set) do
let c = Queue.pop _passive_set in
(** Is the clause [c] suitable for processing?
It must not be processed yet and
not be trivial either. *)
if not (Clause.is_trivial c) &&
not (ClauseSet.mem c !_active_set)
then (
Util.debug 2 "given clause: %a" Clause.pp c;
_add_active c;
_resolve_with c;
_factoring c;
)
done;
`Sat
with
| Unsat -> `Unsat
Main, Options, and other Boring Stuff
We only need to define the glue code that reads a file, converts it into clauses, and calls saturate
to do the real job. Note the use of an error monad. Logtk
provides type inference and an algorithm to transform arbitrary formulas to clauses ("CNF").
(** Read the problem to solve from the file [f],
(try to) solve it and return the result.
We use an error monad to make error handling easier (the
function [>>=] is a {i monadic bind}). *)
let process_file f =
Util.debug 2 "process file %s..." f;
let open Err in
let res =
(** parse the file in the TPTP format *)
Logtk_parsers.Util_tptp.parse_file ~recursive:true f
(** Perform type inference and type checking (possibly updating
the signature) *)
>>= Logtk_parsers.Util_tptp.infer_types (`sign !_signature)
(** CNF ("clausal normal form"). We transform
arbitrary first order formulas into a set of
clauses (see the {!Clause} module)
because resolution only works on clauses.
This algorithm is already implemented in {!Logtk}. *)
>>= fun (signature, statements) ->
let clauses =
Logtk_parsers.Util_tptp.Typed.formulas statements in
let clauses = Sequence.to_list clauses in
(** A way to create fresh symbols for {i Skolemization} *)
let ctx = Logtk.Skolem.create ~prefix:"sk" signature in
let clauses = Logtk.Cnf.cnf_of_list ~ctx clauses in
let clauses = CCList.map Clause._of_forms clauses in
_signature := Logtk.Skolem.to_signature ctx;
(** Perform saturation (solve the problem) *)
Err.return (_saturate clauses)
in
match res with
| `Error msg ->
print_endline msg;
exit 1
| `Ok `Sat -> print_endline "sat"
| `Ok `Unsat -> print_endline "unsat"
(** Parse command-line arguments, including the file to process *)
let _options = ref (
[] @ Logtk.Options.global_opts
)
let _help = "usage: resolution file.p"
let _file = ref None
let _set_file f = match !_file with
| None -> _file := Some f
| Some _ -> failwith "can only deal with one file"
let main () =
Arg.parse !_options _set_file _help;
match !_file with
| None -> print_endline _help; exit 0
| Some f -> process_file f
let () = main()
Conclusion
I wrote this program in a short lapse of time, to illustrate how Logtk
could be used. The result is very naive and has no chance of competing with real provers (such as E). Still, I hope this post will shine some light on the domain of automated theorem proving and maybe — who knows? — get some people interested in the domain. I should point out that I wrote a more serious prover, Zipperposition, using Logtk.
thanks to nicoo and Enjolras on freenode for their second reading.