CP-SAT Data Structures: Trail, Watch Lists, and Clause Database

Under every CDCL solver — including CP-SAT — three interlocking data structures do the real work: an assignment trail with decision-level bookmarks, a two-watched-literal index that wakes only the clauses that matter, and a clause database that grows on every conflict and shrinks on every garbage-collection pass.

Estimated time
~20 min
Difficulty
advanced
Sources
6 sources

CDCL’s algorithmic skeleton — detect a conflict, trace the implication graph, learn a clause, backjump — is elegant on a whiteboard. But an elegant algorithm on hardware that does tens of millions of propagations per second demands an equally elegant memory layout. The three data structures in this lesson are what turn CDCL’s theory into the solver that schedules hospitals and trains.

Coming from the previous lesson?

In “CDCL: Conflict-Driven Clause Learning” you walked through a seven-clause formula, built the implication graph for a conflict at decision level 2, derived the first-UIP learned clause via resolution, and saw how backjumping to the asserting level fires unit propagation for free. This lesson takes the same scenario down one layer: how are the trail, the clauses, and the watcher lists actually represented in memory so that all those operations stay fast?

The Assignment Trail

Every modern SAT solver maintains a flat array called the trail (sometimes called the assignment stack). Each slot records one variable assignment in the order it was made. The trail has a crucial extra dimension: every slot is annotated with its decision level — an integer that counts how many branching decisions have been made so far.

The trail after two decisions

Returning to our seven-clause formula from the previous lesson, after the sequence A=True (decision), C=True (propagated from ¬A∨C), F=False (propagated from ¬A∨¬F), B=True (decision), D=True (propagated from ¬B∨D), E=True (propagated from ¬C∨¬D∨E), the trail looks like this:

IndexVariableValueLevelType
0AT1decision
1CT1propagated via (¬A∨C)
2FF1propagated via (¬A∨¬F)
3BT2decision
4DT2propagated via (¬B∨D)
5ET2propagated via (¬C∨¬D∨E)

Two pieces of bookkeeping live alongside this: a decision level → trail index mapping (level_start[]) that records where each level began, and a propagation queue pointer (sometimes called qhead) that tracks how far unit propagation has consumed the trail. Unit propagation just advances qhead from its current position to the end of the trail, processing each new assignment.

The power of this layout is what backjumping costs. When CDCL decides to backjump to asserting level kk, the implementation does not search for the affected variables — it reads level_start[k+1], slices the trail back to that index, and resets the propagation pointer. The number of variables unset is trail.length - level_start[k+1], each of which is cleared in one array write. No traversal, no searching.

[OR-Tools CP-SAT source: sat/sat_base.h]
Assignment trail def.

A flat array of (variable, value, decision-level) triples, appended in the order assignments are made. A companion array level_start[d] records the trail index at which each decision level dd begins. Backjumping to level kk is a single O(1) slice: reset the trail length to level_start[k+1].

Press Decide to append a decision and its propagated assignments. Use the Backjump control to jump to an earlier level and observe that the trail rewinds in O(1) — only a pointer moves.

Common misconception

Backjumping scans the trail to find and undo the relevant assignments.

What's actually true

It does not. The solver knows the exact index where level k+1 starts (from level_start), so undoing everything above level k is a single array truncation and a loop over the truncated entries to clear each variable’s assignment. No search is needed — this is why the trail layout is chosen in the first place.

Check your understanding

After backjumping to level 1 in the example above, which trail entries remain?

Two-Watched Literals

With the trail understood, we need unit propagation to be fast. In a formula with, say, 100,000 clauses, naively checking every clause after every assignment would be quadratic. The two-watched-literal (2WL) scheme, introduced in the Chaff solver in 2001, cuts this cost dramatically. [Chaff: Engineering an Efficient SAT Solver]

The idea: each clause designates exactly two of its literals as watchers. A global index maps each literal to the list of clauses watching it. When the solver assigns a variable, it only needs to inspect the clauses watching the two literals that just became false — all other clauses are guaranteed to still have at least two unset or true literals, so they cannot yet be unit or conflict.

What happens when a watcher is falsified

Clause C2: (¬C ∨ ¬D ∨ E) starts with watchers on ¬C and ¬D (indices 0 and 1).

  • A=True is decided. Nothing watches A, so C2 sleeps.
  • C=True is propagated. The literal ¬C is now False. C2 wakes up.
  • C2 scans its non-watched literals for a replacement: E is unset. Move the watcher from ¬C to E.
  • C2 now watches ¬D and E. It goes back to sleep.

Later, when D=True is propagated, ¬D becomes False. C2 wakes again.

  • C2 scans non-watched literals: ¬C is now False, not usable. No other literals. No replacement found.
  • The other watcher, E, is unset. Unit clause detected: E must be True.
  • Propagate E=True. Record on the trail, annotated with C2 as the reason clause.

The key insight: a clause whose watchers are both True or both Unset never wakes up, no matter how many other variables are assigned. The cost of unit propagation is proportional to the number of watcher-falsifications, not to the total number of clauses. In practice, most assignments only wake a small fraction of all clauses.

[MiniSat: A Minimalistic and Extensible SAT Solver]
Two-watched literals (2WL) def.

A lazy propagation scheme where each clause maintains two designated literals as watchers. A global watch-list maps each literal to the clauses watching it. A clause is only examined when one of its watchers is falsified; it then either moves the watcher to a new unset/true literal (staying dormant) or reports a unit or conflict to the solver.

Show the formal invariant maintained by 2WL

The invariant is: at all times between propagation rounds, every clause with at least two unset or true literals has both its watchers in that set.

This invariant is maintained by the watcher-move rule: when a watcher is falsified, we search the clause for a non-false literal that is not already watched and move the watcher there. If no such literal exists, the other watcher must be the only non-false literal remaining — producing a unit or, if the other watcher is also false, a conflict.

Crucially, the invariant is lazy — we do not maintain it globally at all times; we re-establish it only when a watcher is falsified (i.e., when the clause is woken). This is the source of the efficiency: clauses with many unset literals never pay any maintenance cost during propagation.

The implementation in sat/clause.h represents each clause as a contiguous array of literals, with the first two slots reserved for the current watchers. Moving a watcher is simply a swap of two array entries. The watch-list is a vector of (clause pointer, literal index) pairs keyed by literal.

Assign variables and watch the two-watched-literal scheme: a clause only wakes when a watched literal is falsified, then moves a watcher or fires as unit/conflict.

Common misconception

Every clause is checked after every assignment to find unit clauses.

What's actually true

This is how DPLL’s naive implementation works — and why it cannot scale past thousands of variables. With 2WL, the solver maintains a watch-list per literal. Assigning variable X only examines clauses watching ¬X (the literal just falsified). Clauses watching X or any other literal are never touched. The speedup on industrial instances is typically two to three orders of magnitude.

Check your understanding

A 5-literal clause (A ∨ ¬B ∨ C ∨ ¬D ∨ E) watches A and ¬B. If ¬B is falsified (i.e., B=True is propagated), what happens next?

The Clause Database and Garbage Collection

Clause learning — the core of CDCL — adds a new clause to the database on every conflict. After solving a problem with millions of conflicts, the database can contain hundreds of thousands of learned clauses. This creates a tension: more clauses mean stronger pruning, but they also slow down unit propagation (more watch-list entries to chase) and consume memory. Modern solvers manage this via periodic garbage collection.

The quality metric used almost universally is Literal Block Distance (LBD), introduced in 2009. [Predicting Learnt Clauses Quality in Modern SAT Solvers (LBD)]

Literal Block Distance (LBD) def.

The number of distinct decision levels among the literals in a learned clause. A clause with LBD = 1 has all its literals at the same decision level — it encodes a tight conflict local to one branch. A clause with LBD = 5 spans five different decision levels — it encodes a complex interaction across much of the search history and tends to be less reusable.

Computing LBD

Learned clause: (¬X₁ ∨ ¬X₃ ∨ X₅) where X₁ was set at level 1, X₃ at level 3, X₅ at level 5.

Distinct levels: 5 → LBD = 3.

Compare with the asserting clause (¬A ∨ ¬B) where both literals were set at level 1: distinct levels 1 → LBD = 1. This binary clause fires every time the solver tries the same level-1 assignments — extremely reusable.

Garbage collection typically runs when the learned clause count exceeds a threshold. The solver sorts clauses by LBD and removes those above a cutoff. Clauses with LBD = 1 or 2 are almost always kept (they are binary or near-binary “glue clauses”). Clauses with high LBD are evicted first.

LBD in CP-SAT's clause.h

CP-SAT’s clause database (sat/clause.h) stores each clause as a compact header followed by a literal array. The header holds the LBD score, a Boolean flag marking clauses as “protected” (recently used in a conflict analysis and therefore worth keeping), and the clause size. The garbage collector iterates through the learned-clause list, promotes recently-active clauses regardless of LBD, and purges the rest above the LBD threshold. The threshold adapts dynamically based on conflict rate and clause growth.

Slide the purge threshold and watch which learned clauses survive: low-LBD glue clauses are kept while high-LBD clauses are garbage-collected.

Common misconception

Deleting learned clauses breaks soundness — the solver might miss a solution or incorrectly declare UNSAT.

What's actually true

Learned clauses are logically redundant: every learned clause is a logical consequence of the original formula. Deleting a learned clause cannot make a SAT instance appear UNSAT or hide a solution — it can only make the solver re-derive the same clause (or an equivalent one) if the same conflict pattern recurs. Completeness is maintained because CDCL still backtracks chronologically past level 0 when the trail empties — the original clauses are never deleted.

Check your understanding

A learned clause has LBD = 7 after a conflict. What does this tell us, and what is likely to happen to it?

How the Three Structures Interlock

The trail, the watch lists, and the clause database are not independent — they form a single feedback loop that is the engine of CDCL.

flowchart LR
  A[Decision
Trail append] --> B[2WL Watch-lists
wake clauses watching ¬x]
  B --> C{Unit or Conflict?}
  C -- Unit --> D[Propagate
Trail append + reason]
  D --> B
  C -- Conflict --> E[Conflict analysis
Implication graph]
  E --> F[New learned clause
Clause database append
Watch 2 literals]
  F --> G[Backjump
Trail slice at level_start]
  G --> H[Unit propagation
learned clause fires]
  H --> B
  style A fill:#1d4ed8,color:#e2e8f0
  style F fill:#065f46,color:#e2e8f0
  style G fill:#92400e,color:#fef3c7
  style E fill:#3b0764,color:#e2e8f0
The CDCL data-structure loop: each structure feeds the next

Reading the loop. Every decision appends to the trail. The trail append triggers the 2WL watch-lists: literals that just became false wake up their watching clauses. If a unit clause fires, its forced assignment appends to the trail (with the clause stored as the reason — needed for implication-graph reconstruction). If a conflict is detected, conflict analysis builds the implication graph from the stored reason clauses, derives a learned clause, appends it to the clause database with two watched literals registered, and backjumps by slicing the trail back to level_start[asserting_level + 1]. The newly added learned clause then fires unit propagation immediately, appending to the trail — and the loop continues.

Assignment Trail Two-Watched Literals Clause Database
Primary role Record assignments in propagation order with level bookmarksWake only the clauses that might become unit/conflictStore original and learned clauses; supply reason clauses for conflict analysis
Backjump cost O(1) — slice at level_start[k+1]O(removed entries) — unwatched literals re-register on restoreNo change — clauses persist across backjumps
Propagation cost O(1) append per assignmentO(falsifications) — sub-linear in practiceO(1) lookup via watch-list per woken clause
Growth over time Bounded by variable countGrows with clause databaseGrows on every conflict; shrunk by GC (LBD threshold)
CP-SAT source file sat/sat_base.h (Trail class)sat/clause.h (WatcherList)sat/clause.h (ClauseDatabase)
The three core data structures: role, cost, and the CP-SAT source file
[OR-Tools CP-SAT source: sat/clause.h]

Check your understanding

When a backjump unwinds the trail to level k, which watch-list entries must be updated?

Check your understandingQ 1 / 5

The assignment trail records assignments in what order, and why does that order matter?