Fast congruence closure and extensions#
Citation#
Robert Nieuwenhuis and Albert Oliveras, Fast Congruence Closure and Extensions, Information and Computation 205(4), 557–580, 2007 [25]. Section numbers refer to this journal edition.
Role in Primo#
This paper is the target reference for Primo’s fast QF_UF theory solver. The
default engine selected with --euf-engine=incremental implements the Section 3
use-list and signature-lookup shape over Primo’s own flat term representation.
The older lazy scaffold remains available with --euf-engine=lazy for
diagnostics. Default-off --euf-explain-search=proof-forest,
reoriented-proof-forest, and structural-proof-forest modes implement
increasingly closer subsets of the paper’s Section 5 explanation machinery.
The reoriented mode implements Section 4/5.2-style raw-endpoint proof forest
reorientation, and the structural mode adds the Section 5.5 structural-class
node refinement. They are not default-ready: current evidence shows correctness
but mixed benchmark effects, and Primo still does not implement proof output or
the paper’s timestamped lookup-table backtracking policy.
Implemented Subsets#
The diagnostic lazy path implements a basic ground-congruence scaffold:
union-find over term ids;
recursive term registration;
repeated saturation for application congruence;
explicit equality assertions;
explanation graph edges labelled by SAT reason literals;
congruence edges justified by child equality explanations.
The default incremental path adds:
deterministic Curry-style flat definitions over
core::TermIdvalues;eager representatives and class lists;
affected-definition use lists;
sparse signature lookup for congruent flat applications;
a pending merge worklist for explicit and congruence merges;
disequality conflict detection;
sparse
push/popcheckpoints and an undo trail for online propagation experiments;drained changed-term notifications that are not resurrected by later pops;
counters for terms, flat definitions, lookup probes and hits, pending pops, congruence merges, conflicts, explanation length, and backtracks.
This is a Section 3 shaped subset, not the complete optimized paper algorithm. Explanations still come from a simple equality edge graph and are sound but not minimal by default. The proof-forest mode is an experimental explanation alternative, not a default or a complete paper implementation. Backtracking trails ordinary mutable closure state rather than using the paper’s optimized timestamped lookup-table policy.
Section 4/5 mechanism#
Sections 4 and 5 of [25] were read on 2026-07-22.
Prior proof-forest experiments (see fast-congruence-closure-implementation.md
audit notes) built proof edges on top of Primo’s existing size-oriented merge
structure and only added the Section 5.2 auxiliary-union-find/HighestNode
explain layer. That is not what the paper specifies, and it explains the
observed “correct traversal, worse clause shape” failure mode: the auxiliary
structure’s efficiency argument depends on the proof forest itself having been
built by the paper’s reorientation procedure, not by union-by-size.
The paper’s actual Section 4 procedure, Union(e, e') (p.567), assuming
w.l.o.g. that the tree of e has no more elements than the tree of e':
Reverse all edges on the path between
eand the root of its own tree.Add an edge
e -> e'.
This is a separate structure from the ordinary Representative/Find union-find used for congruence discovery. Theorem 15 combines a path-compressed union-find with a separate parent array for the proof forest. Its two parent relations serve different purposes and must be maintained independently. Lemma 13: each proof-forest edge is reoriented at most O(log n) times over a sequence of n-1 unions, because each reversal at least doubles the reversed edge’s tree size – this is the amortized bound the whole approach depends on, and it requires the tree to actually be a proof forest built this way, not a relabeled copy of the size-based merge tree.
Section 5.2 extends this to congruence closure: Propagate additionally
inserts one proof-forest edge per merge, labelled either with the single input
equation (a=b) or, for a congruence step, the pair of structural equations
(f(a1,a2)=a, f(b1,b2)=b) (recursively explained). Explain(c1,c2) computes
the nearest common ancestor via an auxiliary union-find with a HighestNode
operation (the node of a class closest to the forest root), then walks both
sides to that ancestor via ExplainAlongPath, unioning each traversed node
into the auxiliary structure so a later recursive sub-explanation (from a
congruence-step label) can jump directly over already-output subpaths instead
of re-walking them. Theorem 17: a k-step proof is found in O(k * alpha(k,k)).
Section 5.4 is the quality-of-explanations analysis, and it is directly relevant to the clause-size regression: the algorithm as given always returns the oldest explanation (shortest is NP-hard, Ashish Tiwari, personal communication), and Theorem 21 proves this can still contain redundant steps – only when the proof contains three equations of the same structural class (same representative pattern). On the paper’s own benchmark set (~20k proofs), 13.92% of proofs had redundant steps, averaging 51 steps with 6 redundant when redundant.
Section 5.5 is the fix for that: build the proof forest over structural classes (maximal groups of constants already connected by direct, non- congruence equations) rather than over individual constants, so a congruence step merges two whole classes at once instead of adding an edge between two arbitrary representatives. This alone drops the redundancy rate from 13.92% to 3.3% (Theorem 24 gives an even tighter “two equivalent non-singleton structural classes” redundancy condition for this variant). This – not the reorientation alone – is the most likely reason a correctly-reoriented plain proof forest could still occasionally produce a larger-than-BFS reason.
Scope decision for the next implementation attempt: build the plain (non-structural-class) Section 4 + 5.2 forest first, correctly reoriented, tested against the paper’s own worked examples, and measure clause quality before deciding whether Section 5.5’s structural-class refinement is needed on top. The paper itself treats 5.5 as a refinement of an already-correct 5.2 forest, not a prerequisite for correctness.
2026-07-22 status#
src/theory/equality/proof_forest.{hpp,cpp} implements the plain Section 4 +
5.2 forest as a standalone class (ProofForest), independent of
FlatCongruenceClosure:
union_nodes(e, e_prime, label)implements Section 4’s Union exactly: walks frometo the root of e’s own tree, reverses every edge on that path (moving each edge’s label with it), then linkse -> e_primewith the new label. Edges connect the caller-supplied raw nodes directly, not representatives – the prior attempt’s “endpoint-detour obligation” workaround (needed only because it linked via representatives) does not exist here because there is nothing to work around.explain(c1, c2)implements Section 5.2 self-contained: nearest common ancestor via an auxiliary union-find withHighestNode, reset per top-level call, and recursive congruence-obligation resolution within the same call (the paper’sPendingProofsloop), returning a flat list of direct-equation reason ids.Backtracking (
push/pop) is not in the paper (its Union sequence is online-only); this is Primo’s own extension. Eachunion_nodescall records the pre-mutation(node, parent, label)triple for every node on the reversed path in one trail batch, so popping restores the exact prior state in one step, undoing both the reversal and the link.structural-proof-forestadds Section 5.5-style structural-class nodes as an opt-in mode; it is correct on the recorded tests and fuzzing, but not promoted because the benchmark evidence remains mixed.
Verified with hand-derived tests in tests/unit/euf_solver_tests.cpp
(proof_forest_*) that specifically exercise: reversal when e is not
currently a root (the exact case the paper’s procedure exists to handle,
constructed and traced by hand against the algorithm, not copied from a
diagram); recursive congruence-obligation explanation; backtracking; and the
disconnected/trivial cases. All pass under the default and ASAN test suites.
Integrated and benchmarked the same day. FlatExplanationSearchMode:: ReorientedProofForest wires ProofForest into FlatCongruenceClosure:: merge_classes, calling union_nodes(proof_from_endpoint, proof_to, label)
with the pending merge’s raw endpoints (the same variables the prior,
representative-based record_proof_parent call already computed, just used
correctly this time) instead of lhs_repr. push/pop call the forest’s
own push/pop in lockstep so checkpoint indices line up. Passes the
existing randomized 180-step interleaved-backtracking oracle test
(flat_congruence_closure_explanations_match_naive_oracle_under_backtracking)
under both the default and ASAN suites.
One implementation bug found and fixed before measuring: ProofForest:: explain originally reset the auxiliary union-find with an eager O(n)
assign on every top-level call. With ~180K top-level Explain calls on
PEQ002_size8, that reset alone dominated wall time despite the algorithm
being asymptotically correct. Fixed with the generation-stamp pattern used
elsewhere in this file (bump a counter, lazily reinitialize each node the
first time it’s touched under the new generation) – this is a real lesson
for anyone reimplementing Section 5.2’s “reset the auxiliary structure”:
literally resetting an array does not scale in a system that calls Explain
once per theory conflict.
Local matrix on the two cases that sank the prior (representative-based) attempt, current-branch Release build, single uncontended run each:
file |
mode |
explain-calls |
conflicts |
max reason len |
seconds |
|---|---|---|---|---|---|
PEQ002_size8 |
default (BFS) |
31,655,604 |
99,698 |
173 |
15.5 |
PEQ002_size8 |
reoriented-proof-forest |
183,286 |
179,492 |
149 |
35.5 |
NEQ016_size7 |
default (BFS) |
5,352,993 |
41,495 |
118 |
3.5 |
NEQ016_size7 |
reoriented-proof-forest |
45,228 |
43,765 |
130 |
3.1 |
Both answers remain unsat (sound) on both files, and explain-calls drop by
100-170x as the paper’s complexity bound promises. But this is a mixed, not a
clean, result:
NEQ016_size7is a real, if modest, win: 40% fewer SAT conflicts (18,221 -> 11,013) and slightly faster wall time.PEQ002_size8is a real loss: 1.8x more theory conflicts overall (99,698 -> 179,492), and gprof shows the extra time is not inProofForest(whose own functions are now cheap, ~5% self-time combined) but inCaDiCaL::Closure::find_subsuming_clause, whose absolute cost rose ~10x (1.27s -> 12.11s) – the larger, more numerous clauses this mode produces make CaDiCaL’s own clause-database subsumption bookkeeping far more expensive.
This reproduces, with a now-provably-correct implementation, the exact mixed pattern every prior (representative-based, therefore differently broken) proof-forest attempt hit: the tree-structured Explain path is cheap to compute but is not the graph-shortest path BFS finds, and on some instances that costs more in SAT-search terms than it saves in explain-computation terms. This is direct evidence for the Section 5.4/5.5 hypothesis above – structural-class nodes (5.5) are the documented fix for exactly this kind of “correct but not high quality” explanation, and remain the next thing to try, not this reorientation-only forest.
Decision: kept as an opt-in mode (--euf-explain-search= reoriented-proof-forest), not promoted to default. It is a correct,
independently useful building block (worth keeping given the drastic
explain-cost reduction and the NEQ016_size7 win), but not a net win on its
own, matching this project’s standing policy of not promoting a change on
a 2-file local matrix. See docs/run-log.json for the run record.
2026-07-22 status: Section 5.5 structural-class nodes (correct, but blocked on a performance regression)#
Implemented on top of ProofForest via two new public methods,
coalesce_into_structural_class(member, canonical, label) and
resolve(node), wired into FlatCongruenceClosure::merge_classes as a
third mode, --euf-explain-search=structural-proof-forest.
What a structural class actually is. Re-reading Section 5.5 and
Theorem 21 carefully (the previous status note above had this slightly
wrong): a structural class is not “constants pre-merged by direct
equations”. It is the set of congruence-produced terms sharing one
(function, argument) representative pair – e.g. f(a1,a2)=a,
f(b1,b2)=b, f(c1,c2)=c are one class iff a1~b1~c1 and a2~b2~c2
(Example 22). The paper coalesces every such term into a single
proof-forest node (no edge between them at all) instead of linking them by
tree edges. This codebase’s lookup_ signature table
(ApplyKey{rep(function), rep(argument)} in
flat_congruence_closure.cpp) already computes exactly this partition as
a side effect of ordinary congruence detection: every enqueue_congruence
call fires precisely when a new definition’s result collides with
lookup_’s existing entry for that key, i.e. every congruence merge in
this codebase is already “one new structural-class member joining the
class’s existing canonical member.” That is the hook
coalesce_into_structural_class uses.
Three soundness bugs found and fixed, all one root cause. A node’s own signature gets rehashed and can be rediscovered congruent more than once over its lifetime, as unrelated merges elsewhere change its own arguments’ representatives – so a node already used as a structural-class canonical, or already itself a coalesced member, is routinely asked to join another class later. Naively resolving to “the current identity” and linking through that, instead of the raw node, silently drops whichever bridge fact got resolved away from the explanation – unsound, not just suboptimal. Concretely:
ProofForest::explain’s recursive obligation loop originally only alias-resolved the top-level(c1, c2)query; obligations produced during the walk (from a congruence edge’s own sub-obligations, or from a bridge label further down an alias chain) were fed tonearest_common_ancestorraw, with no guarantee they were tree-identity nodes. Fixed by resolving (and re-seeding bridge obligations for) both sides of every obligation the main loop pops, not just the initial pair.Re-coalescing an already-aliased member by pointing its alias slot at the new canonical (or by linking the two resolved identities and reusing the same label) discards the member’s earlier bridge fact entirely, since nothing else in the tree ever visits that member’s raw id again. Fixed by adding
ProofForest::materialize(node): converts an existing alias into a real tree edge first (exactly what a non-coalescing Union would have produced), so the new fact can be linked on top of it via ordinary Union and Section 4’s own path-reversal handles reconnecting everything.The same problem applies symmetrically to
canonical(a canonical is only guaranteed alias-free at the moment it is first recorded inlookup_, not forever) and to direct-equation endpoints (an equation can directly mention an already-coalesced member, the paper’s own Example 23). Both call sites nowmaterializebefore linking. A related trap:parent_[node] == nodealone does not meannodeis safe for the cheap alias fast path, becauseunion_nodesnever rewritesparent_[e_prime]– a self-rooted node can already have a real incoming edge from an earliere. Addedhas_incoming_edge_(set one_primeinsideunion_nodes, trailed for backtracking) to catch this; the fast alias path now additionally requires!has_incoming_edge_[member].Found by fuzzing, not by the tests above. The hand-derived Example 22/23 tests and the 180-step randomized oracle test all passed after fix 3 – and still missed a fourth instance of the same class of bug: a node could become an alias target (something else’s
coalesce_into_structural_classfast-path pointed at it) withouthas_incoming_edge_being set, since that flag was only set insideunion_nodesfor real tree edges. Such a node could then itself be fast-aliased away, leaving whoever pointed at it via a latermaterialize()-created real tree edge dead-ended at a self-rootedparent_the ordinary tree walk cannot see past (it only followsparent_, neveralias_, past the initial query). This surfaced as an uncaughtstd::logic_error(“flat congruence closure failed to explain disequality conflict”) fromFlatCongruenceClosure::check_disequality– a different call path than the publicexplain_equalthe unit tests exercise.scripts/fuzz_external.py --fuzzer fuzzsmtagainst z3 found it in the first 500 generated QF_UF instances (6 crashes);ddsmtshrank one to an 8-line reproduction in two passes. Fixed by markinghas_incoming_edge_[canonical]on the fast alias path too, not only insideunion_nodes.
Verified against hand-derived unit tests built directly from the paper’s
Example 22 and Example 23 (proof_forest_structural_class_* in
tests/unit/euf_solver_tests.cpp, same rigor as the existing
proof_forest_* tests – traced by hand against the paper’s own worked
examples, not copied from a diagram), the existing 180-step randomized
backtracking oracle test extended to cover this mode, and – after finding
fix 4 required going beyond what those catch – a 2000-instance
fuzzsmt-against-z3 round against an ASAN/UBSan build (0 crashes, 0
reference disagreements). All pass. The lesson for next time: for a
data structure this stateful, run the fuzz gate during development, not
only benchmarks after – it found in under a minute what several rounds of
hand-tracing benchmark failures took a full session to isolate.
Performance: two real bugs, both found by profiling and fixed. With the
crash fixed, the project’s standard 334-file primo.html benchmark subset
(benchmarks/user-primo-html334-2026-07-18.txt) initially gave a clean but
bad comparison: default 270/334 solved (PAR2 7.500), reoriented-proof-forest
259/334 (7.982), structural-proof-forest 232/334 (9.329) – a clear,
consistent net loss, and NEQ016_size7 standalone did not finish in 60+
seconds (versus 3.1s for the other two modes). perf record on
NEQ016_size7 (bounded to a 20s window, since the process did not
terminate on its own) showed why: ~76% of all samples were inside
ProofForest::explain and its two NCA-search helpers, aux_find (38%) and
nearest_common_ancestor (13%) – the opposite of every other mode’s
profile, where theory/SAT-search code dominates and the explain family
barely registers. Two distinct causes, found and fixed in sequence:
Tree depth wasn’t bounded. Lemma 13’s amortized O(log n) reversal bound requires every Union to reverse the smaller side’s path, so a given edge’s subtree size at least doubles between reversals.
reoriented-proof-forest’s direct-equation caller already gets this right (chooses e/e’ by liveclass_size_before callingunion_nodes).coalesce_into_structural_class’s fallback branch andmaterializeboth created real tree edges by semantic role (member vs canonical, alias source vs target) with no size discipline at all – and once fast-path aliasing exists, live class size stops being an accurate proxy for real tree size anyway (an aliased member inflates its live class without adding real tree depth). Fixed by givingProofForestits own tree-size tracking (size_) and makingunion_nodesitself swap which physical node playsevse'when the caller’s choice disagrees with size – sound regardless of caller intent, since a tree edge’s label proves its two endpoints equal however it is attached. Confirmed via direct instrumentation: max real tree depth stayed at 46 after 200,000union_nodescalls onNEQ016_size7, versus unbounded growth before.Alias-bridge obligations weren’t deduplicated within a call. Fixing (1) alone did not fix
NEQ016_size7. Further instrumentation showedseed_and_resolve(the alias-chain walker insideexplain) being called over a billion times in 15 seconds against only ~5,000 top-levelExplaincalls in that window. The auxiliary union-find (aux_find/aux_union_to) already deduplicates repeat tree-edge congruence obligations reached viaexplain_along_path’s NCA walk – that compression is what Section 5.2’s own complexity argument depends on. It does not cover alias-bridge obligations reached viaseed_and_resolve: a coalesced node’s bridge obligations were pushed ontopending_proofsevery time any obligation referenced it, with no memoization, and structural-class coalescing routinely produces a few popular “hub” canonical nodes referenced by many different obligations within oneExplaincall. Fixed with the same generation-stamped mark pattern already used foraux_generation_/ancestor_generation_in this file: a node’s bridge obligations are pushed at most once per top-levelExplaincall.
Combined, both fixes take NEQ016_size7 from not finishing in 180+ seconds
to solving in 3.67s – in line with default (3.11s) and
reoriented-proof-forest (3.05s) – and move the 334-file comparison from a
clear net loss to close to competitive:
mode |
solved / 334 |
PAR2 |
|---|---|---|
default |
262 |
7.989 |
reoriented-proof-forest |
254 |
8.279 |
structural-proof-forest |
248 |
8.643 |
(Absolute counts shift a few files run-to-run from shared-core contention
during the 14-way parallel sweep – default and reoriented’s own code did
not change between the two sweeps – so the meaningful comparison is
structural’s relative position: only 6 files behind reoriented now,
versus 27 before these fixes, with divergence down to 20/334 from 43/334.)
Verified against unit tests, a freshly-configured ASAN/UBSan build, and a
2000-instance fuzzsmt-against-z3 round after each fix (0 crashes, 0
disagreements both times).
Decision: implementation is verified correct (fuzzed clean) and is now
close to competitive with reoriented-proof-forest rather than a clear
performance loss – still not a clear net win, so still not promoted to
default. Kept as an opt-in mode (--euf-explain-search= structural-proof-forest). The remaining 6-file gap has not been profiled
further this session; that would be the natural next step before any
promotion decision. See docs/run-log.json for the run record.
Remaining implementation checklist#
Before claiming that Primo implements the full Nieuwenhuis-Oliveras fast congruence closure, the code must add and document:
a default-ready Section 4/5 proof-forest explanation policy with broad benchmark evidence, not only opt-in modes;
the paper’s timestamped lookup-table backtracking policy;
pure-class performance probes showing the Section 3 machinery improves the intended QF_UF families;
benchmark evidence showing solved-count or PAR2 improvement without wrong answers.
Deferred#
optimized incremental backtracking
default-ready optimized proof-forest explanation machinery
proof production
Data structures#
Diagnostic lazy scaffold:
union-find over
TermId;set/vector of registered terms;
explicit explanation edge graph.
Default incremental subset:
flat term definitions;
equivalence classes;
application lookup/signature table;
affected-definition use lists;
pending merge queue;
disequality set;
explanation graph.
Invariants#
Current scaffold invariants:
all inspected terms are created by
core::TermManager;equality is over
TermId, not SMT-LIB AST nodes;explanations contain SAT literals supplied by the SMT layer.
Incremental subset invariants:
lookup probes are built from current representatives; older lookup keys may remain after representative changes and are ignored by current-signature probes;
every pending merge is either processed or invalidated by backtracking;
use lists contain applications that must be reconsidered after a class merge; duplicate entries are tolerated and deduplicated when an affected list is processed;
explanation edges only cite SAT literals supplied by the SMT layer.
Tests derived from the paper#
equality/disequality contradiction
unary congruence
binary congruence
nested congruence
lookup-table congruence discovery without scanning all term pairs
backtrack rediscovery
proof-forest explanation paths, including reorientation, recursive congruence-obligation resolution, backtracking, and structural-class coalescing.