wiki

Congruence closure

Congruence closure computes all equalities between terms that follow from a given set of equations, under the rules of equality and the congruence rule: if , then for every function symbol [1][2]. The function symbols are uninterpreted: nothing is known about them except that they are functions. Given ground equations and disequations, the algorithm partitions the subterms into equivalence classes, merging classes for each equation and then merging any applications whose argument classes coincide, until nothing changes; the input is satisfiable exactly when no disequation relates two terms of the same class. This decides the quantifier-free theory of equality with uninterpreted functions (EUF), one of the basic theories of SMT solvers, where congruence closure serves as the theory solver[4]. Nelson and Oppen, and Downey, Sethi and Tarjan, published efficient algorithms in 1980[1][2].

§ 01

The theory

Equality with uninterpreted functions is axiomatized by reflexivity, symmetry, transitivity and congruence:

A conjunction of equations and disequations between ground terms is satisfiable if and only if no disequation has derivable from the equations by these rules. Ackermann showed in 1954 that the problem is decidable[6], and Kozen that it is decidable in polynomial time[5]. Uninterpreted functions are useful as abstractions: replacing an operation that is hard to reason about, such as multiplication or a hash function, by an uninterpreted symbol keeps only the fact that equal inputs give equal outputs, which is often all a proof needs.

§ 02

Algorithm

Represent the subterms of the input as the nodes of a directed acyclic graph, sharing identical subterms. Keep the nodes in a union–find structure, and for each class the list of applications that use one of its members as an argument. To merge two classes, link their representatives and recompute the signature of each application that used the smaller class, the function symbol together with the representatives of its arguments. If another application already has the same signature, the two are congruent and are merged in turn[2].

Congruence closure with use lists and a signature table, printing each merge.

(* Congruence closure with use lists, in the style of Downey, Sethi and
Tarjan. Terms are nodes of a DAG: constants, or a function symbol
applied to argument nodes. *)
type node = { id : int; sym : string; args : int list }
type t = {
nodes : node array;
parent : int array; (* union–find *)
uses : int list array; (* applications using a class as an argument *)
sigs : (string * int list, int) Hashtbl.t; (* signature -> a node with it *)
}
let trace = ref false
let rec find cc i = if cc.parent.(i) = i then i else (let r = find cc cc.parent.(i) in cc.parent.(i) <- r; r)
let signature cc n = (n.sym, List.map (find cc) n.args)
let make (nodes : node array) =
let n = Array.length nodes in
let cc = { nodes; parent = Array.init n Fun.id; uses = Array.make n [];
sigs = Hashtbl.create 64 } in
Array.iter (fun nd -> List.iter (fun a -> cc.uses.(a) <- nd.id :: cc.uses.(a)) nd.args) nodes;
cc
let rec show cc i =
let nd = cc.nodes.(i) in
if nd.args = [] then nd.sym else nd.sym ^ "(" ^ String.concat "," (List.map (show cc) nd.args) ^ ")"
(* Merge the classes of a and b. The applications that used the smaller
class get new signatures; any that now collide with another
application are congruent, and are merged in turn. *)
let rec merge cc a b why =
let ra = find cc a and rb = find cc b in
if ra <> rb then begin
if !trace then Printf.printf " merge %s = %s (%s)\n" (show cc a) (show cc b) why;
let ra, rb = if List.length cc.uses.(ra) <= List.length cc.uses.(rb) then (ra, rb) else (rb, ra) in
let moved = cc.uses.(ra) in
cc.parent.(ra) <- rb;
cc.uses.(rb) <- moved @ cc.uses.(rb);
List.iter (fun u ->
let s = signature cc cc.nodes.(u) in
match Hashtbl.find_opt cc.sigs s with
| Some v when find cc v <> find cc u -> merge cc u v "congruence"
| Some _ -> ()
| None -> Hashtbl.replace cc.sigs s u) moved
end
(* Build the structure; applications that have equal signatures from
the start are congruent and are merged at once. *)
let create nodes =
let cc = make nodes in
Array.iter (fun nd ->
if nd.args <> [] then
match Hashtbl.find_opt cc.sigs (signature cc nd) with
| Some v when find cc v <> find cc nd.id -> merge cc v nd.id "congruence"
| Some _ -> ()
| None -> Hashtbl.replace cc.sigs (signature cc nd) nd.id) nodes;
cc

Merging the smaller use list into the larger means that each application is re-signed times, so the whole closure takes time with a hash table for signatures, for a graph with edges[2].

§ 03

An example

From and it follows that . By congruence, gives and ; with this makes , and then , so .

The example.

open Cc
(* a, f(a), f(f(a)), ..., f^5(a) as nodes 0 .. 5 *)
let chain = Array.init 6 (fun i -> if i = 0 then { id = 0; sym = "a"; args = [] }
else { id = i; sym = "f"; args = [ i - 1 ] })

Running it.

assert f(f(f(a))) = a
merge f(f(f(a))) = a (given)
merge f(f(f(f(a)))) = f(a) (congruence)
merge f(f(f(f(f(a))))) = f(f(a)) (congruence)
assert f(f(f(f(f(a))))) = a
merge f(f(f(f(f(a))))) = a (given)
merge f(f(f(a))) = f(a) (congruence)
f(a) = a? true
classes: 1
givengivenmerged by congruenceaf(a)f²(a)f³(a)f⁴(a)f⁵(a)
The terms of the example as a graph, each application pointing to its argument. The two given equations, below, start the process; the merges by congruence, above, follow from them, until all six terms are in one class.

Comparing the use-list algorithm with a naive closure that recomputes all signatures until nothing changes, on random term graphs.

(* Random term DAGs over a, b, f and g, and random equations: does the
use-list algorithm agree with naive repeated closure? *)
let rec to_term (nodes : Cc.node array) i =
let n = nodes.(i) in
if n.args = [] then Euf.Const n.sym else Euf.App (n.sym, List.map (to_term nodes) n.args)

Running it.

500 random problems, 500 agree on all 72000 pairs of terms
§ 04

In SMT solvers

As a theory solver, congruence closure must be incremental, adding equations one at a time as the SAT solver asserts them; backtrackable, undoing merges when the SAT solver backtracks; and able to explain its conclusions. When a disequation is contradicted, the solver must name a small set of input equations from which follows, which becomes the learned clause. Nieuwenhuis and Oliveras showed how to record, for each merge, the equation or congruence that caused it, and to extract explanations in time proportional to their size[4]. The Simplify prover, the basis of many program verifiers, was built around an e-graph of this kind[11].

An alternative is to remove the functions altogether. Ackermann's reduction replaces each application by a fresh variable and adds, for each pair of applications of the same function, the constraint that equal arguments imply equal results. The result is a formula in the theory of equality alone, at the cost of a quadratic number of constraints[6][10].

§ 05

E-graphs

The congruence-closure data structure, a set of term nodes grouped into classes and kept closed under congruence, is called an e-graph. Compilers use it for global value numbering, to find expressions that are guaranteed to compute the same value[7]. Equality saturation applies rewrite rules to an e-graph without discarding the original terms, so that all equivalent forms of a program are represented at once, and then extracts the best one[8]. The egg library made the technique practical for program optimizers and synthesis tools[9].

§ 06

History

Ackermann decided the ground case of equality with functions in 1954[6], and Kozen gave a polynomial algorithm in 1977[5]. Shostak described an equality procedure for program verification in 1978[3]. In 1980 Nelson and Oppen presented congruence closure as the decision procedure for EUF, as part of their method for combining decision procedures[1], and Downey, Sethi and Tarjan gave the algorithm in the context of common subexpressions[2]. Proof-producing congruence closure for SMT followed in 2007[4], and the e-graph returned to compilers with equality saturation in 2009[8][9].

see also

referenced by

further reading

  1. [1]G. Nelson, D. C. Oppen, “Fast decision procedures based on congruence closure”, Journal of the ACM 27 (1980).
  2. [2]P. J. Downey, R. Sethi, R. E. Tarjan, “Variations on the common subexpression problem”, Journal of the ACM 27 (1980).
  3. [3]R. E. Shostak, “An algorithm for reasoning about equality”, Communications of the ACM 21 (1978).
  4. [4]R. Nieuwenhuis, A. Oliveras, “Fast congruence closure and extensions”, Information and Computation 205 (2007).
  5. [5]D. Kozen, “Complexity of finitely presented algebras”, STOC (1977).
  6. [6]W. Ackermann, Solvable Cases of the Decision Problem, North-Holland (1954).
  7. [7]B. Alpern, M. N. Wegman, F. K. Zadeck, “Detecting equality of variables in programs”, POPL (1988).
  8. [8]R. Tate, M. Stepp, Z. Tatlock, S. Lerner, “Equality saturation: a new approach to optimization”, POPL (2009).
  9. [9]M. Willsey, C. Nandi, Y. R. Wang, O. Flatt, Z. Tatlock, P. Panchekha, “egg: fast and extensible equality saturation”, POPL (2021).
  10. [10]D. Kroening, O. Strichman, Decision Procedures: An Algorithmic Point of View, ch. 4, Springer (2nd ed., 2016).
  11. [11]D. Detlefs, G. Nelson, J. B. Saxe, “Simplify: a theorem prover for program checking”, Journal of the ACM 52 (2005).

last updated