The CP-SAT Propagator Registry and Solver Loop

CP-SAT's power comes from its hybrid architecture: a CDCL Boolean engine and a registry of CP propagators that share a single trail, watch-list, and clause database. When a CP propagator detects a domain reduction, it encodes it as a lazy Boolean clause — posted to the shared trail so CDCL conflict analysis can resolve against it like any original clause.

Estimated time
~20 min
Difficulty
advanced
Sources
6 sources

Coming from the previous lesson?

In “CP-SAT Data Structures: Trail, Watch Lists, and Clause Database” you saw how the assignment trail records every decision and propagation in level-annotated order, how two-watched literals let unit propagation skip irrelevant clauses, and how the clause database grows on every conflict and shrinks via LBD-based garbage collection. This lesson takes one layer up: how do CP propagators plug into that infrastructure, and what happens when one fires?

A scheduler’s intuition says: “Teacher A is in Room 1 at 9 am, so she obviously cannot also be in Room 1 at 10 am.” A pure Boolean SAT solver has no idea that those two assignments are related — it would discover the conflict only after both are forced and a Boolean clause fires. CP-SAT’s propagator architecture is what closes that gap: it gives a higher-level constraint reasoner a direct line into the Boolean engine’s nervous system.

The CpPropagator Interface: Registering with the Solver

Every constraint in CP-SAT that needs runtime inference — AllDifferent, LinearConstraint, AtMostOne, Cumulative — is implemented as a propagator: an object that inherits from CpPropagator in sat/constraint_programming_propagator.cc and registers itself with the solver at model-build time.

The CpPropagator contract (from sat/constraint_programming_propagator.cc)

The interface has three key entry points:

class CpPropagator {
 public:
  // Called when the solver wants this propagator to run.
  // Returns false if a conflict was detected.
  virtual bool Propagate() = 0;

  // Called when backjumping unwinds the trail past a level this
  // propagator cares about. Lets the propagator undo any internal state.
  virtual void Untrail(int trail_index) {}

  // The propagator registers which integer variables it watches.
  // The solver calls Propagate() only when one of those variables
  // has had its domain reduced since the last call.
  void RegisterWith(GenericLiteralWatcher* watcher);
};

RegisterWith is where the magic begins. The propagator passes a list of IntegerVariable objects (or the Boolean literals that encode them) to the GenericLiteralWatcher. From that point on, the solver only calls Propagate() when at least one of those variables has changed — exactly the same lazy-wakeup logic as two-watched literals, applied one layer up.

CpPropagator def.

An object in sat/constraint_programming_propagator.cc that encodes one constraint’s inference logic. It registers a watch-set of integer variables with the solver; the solver calls its Propagate() method only when the trail advances past an assignment touching that set. When Propagate() detects a new domain reduction or conflict, it posts it as a Boolean literal (or clause) to the shared trail.

The registry itself lives in SatSolver as an ordered list of CpPropagator* pointers. The solver iterates them in registration order during every CP propagation round. Order matters for efficiency (cheaper propagators first reduces the cost per round) but not for correctness.

[CP-SAT source: sat/constraint_programming_propagator.cc]

Check your understanding

Why does registering a watch-set make CP propagators efficient, rather than calling every propagator after every assignment?

The Solver Loop: CDCL and CP Interleaved

Now we can read the main loop in sat/sat_solver.cc with real understanding. The loop is not simply “CDCL OR CP” — it is a nested cycle where CDCL runs first, then CP, and neither layer branches until both have quiesced.

flowchart TD
  A([Branch: add decision to trail]) --> B
  B[CDCL Boolean Propagation
unit-propagate all clauses
original + learned + lazy] --> C{New assignments?}
  C -- Yes --> B
  C -- No: CDCL quiesced --> D
  D[Call CP Propagators in order
for each registered CpPropagator] --> E{Any propagator
posted new literal?}
  E -- Yes: re-run CDCL --> B
  E -- No: full quiescence --> F
  F{All variables
assigned?} -- Yes --> G([SAT — solution found])
  F -- No --> A
  B -- Conflict detected --> H[Conflict Analysis
implication graph
first-UIP cut]
  D -- Conflict detected --> H
  H --> I[Learn clause
post to DB
backjump on trail]
  I --> B
  style B fill:#1e3a8a,color:#bfdbfe
  style D fill:#4c1d95,color:#ddd6fe
  style H fill:#7f1d1d,color:#fca5a5
  style I fill:#065f46,color:#a7f3d0
  style F fill:#374151,color:#d1d5db
The CP-SAT solver loop: CDCL Boolean propagation runs first; CP propagators are called only after CDCL quiesces; a new lazy clause restarts CDCL

The ordering rule is important and non-obvious: CDCL always runs first. If a branch decision immediately propagates to a conflict via a Boolean clause, there is no point calling CP propagators — the solver needs to backjump immediately. Only when the Boolean layer has nothing more to say does the solver hand control to CP propagators. And when a CP propagator posts a new literal, the loop immediately returns to CDCL — the new trail entry may wake Boolean watch-lists that fire further unit clauses.

[CP-SAT source: sat/sat_solver.cc (SatSolver::Solve)]

Analogy — Two-Watched Literals (clause level) is like CP Propagator Watch-sets (constraint level)

A Boolean clause wakes only when one of its two watched literals is falsified. A CP propagator wakes only when one of its registered integer variables is reduced. In both cases the principle is the same: pay attention only when the situation might have changed for the worse.

Step the solver loop and watch CDCL Boolean propagation interleave with CP propagator calls until quiescence, then a branch.

Check your understanding

CDCL finishes unit propagation with no new assignments. A CP propagator then runs and posts a new literal to the trail. What happens next?

Lazy Clause Generation: CP Talks to CDCL

When a CP propagator detects that a domain reduction is forced — or that the current assignment creates a conflict — it cannot just silently update some internal state. It must communicate with the CDCL engine in CDCL’s language: Boolean literals and clauses.

This is lazy clause generation (LCG), first described in the constraint-programming literature by Ohrimenko, Stuckey, and Codish in 2009. [Lazy Clause Generation Reengineered for Constraint Programming]

Lazy clause generation def.

A technique in which CP propagators do not pre-enumerate all consequences of a constraint. Instead, each time a propagator detects a forced domain reduction or conflict, it generates a single Boolean clause that justifies the reduction from the current assignment. This “lazy clause” is posted to the shared clause database and participates in CDCL conflict analysis just like an original clause.

Worked trace: the AtMostOne constraint for school timetabling

Constraint: AtMostOne({TeachA_R1_T3, TeachA_R1_T4}) — Teacher A cannot be in Room 1 at both Time 3 and Time 4.

Step 1 — Decision. The solver branches: TeachA_R1_T3 = True at decision level 1.

Step 2 — CDCL propagation. A binary clause (¬TeachA_R1_T3 ∨ hasRoom_T3) fires, forcing hasRoom_T3 = True. CDCL exhausts; no more unit clauses.

Step 3 — CP propagator called. The AtMostOne propagator is woken (it watches both TeachA_R1_T3 and TeachA_R1_T4). It observes TeachA_R1_T3 = True. By the constraint, TeachA_R1_T4 must be False (= 0).

Step 4 — Lazy clause generated. The propagator cannot just write TeachA_R1_T4 = False — the trail entry needs a reason so CDCL can reconstruct the implication graph during conflict analysis. The propagator posts the lazy clause:

(¬TeachA_R1_T3¬TeachA_R1_T4)(\neg \text{TeachA\_R1\_T3} \vee \neg \text{TeachA\_R1\_T4})

This is a valid logical consequence of the AtMostOne constraint. It is appended to the clause database with two watched literals, exactly like a learned clause.

Step 5 — Lazy clause fires as unit clause. TeachA_R1_T3 = True makes ¬TeachA_R1_T3 = False. The only remaining literal, ¬TeachA_R1_T4, is forced True — meaning TeachA_R1_T4 = False. This propagation lands on the trail with the lazy clause as its reason.

Step 6 — CDCL re-runs. The new trail entry wakes any clauses watching ¬TeachA_R1_T4. In this scenario none fire further. CDCL quiesces again.

Step 7 — All CP propagators re-called. None can derive new reductions. Full quiescence. The solver branches on the next unassigned variable.

The key insight: if a later conflict involves TeachA_R1_T4, conflict analysis will chase the reason pointer on that trail entry, find the lazy clause (¬TeachA_R1_T3 ∨ ¬TeachA_R1_T4), and resolve against it — just as it would against any original clause. The first-UIP cut and the backjump level computation from the previous lesson apply unchanged. CP and CDCL share the same trail infrastructure completely.

[CP-SAT source: sat/integer.h (IntegerEncoder, IntegerLiteral)]
Follow the AtMostOne propagator as it fires on TeachA_R1_T3=True, generates a lazy clause, and watches it fire as a unit clause that forces TeachA_R1_T4=False.

Common misconception

CP propagators bypass CDCL — they directly set variables, and CDCL just sees the results.

What's actually true

CP propagators cannot silently modify domain state outside the trail. Every domain reduction must be posted as a literal to the shared trail with a reason clause. That reason clause is what lets CDCL’s conflict analysis build the full implication graph and compute the correct asserting level for backjumping. A propagator that hides its reasoning breaks the trail’s causality record and produces wrong backjumps or missed conflicts.

Check your understanding

Why must a CP propagator supply a reason clause when posting a domain reduction, rather than just asserting the literal directly?

Domain Reductions as Boolean Literals

For lazy clause generation to work, there must be a systematic mapping from integer domain facts (e.g., “X ≥ 3”) to Boolean literals that CDCL can reason about. This encoding lives in sat/integer.h.

Integer literal (sat/integer.h) def.

A pair (variable, bound) encoding either a lower-bound fact or an upper-bound fact about an integer variable X:

  • LB literal [X ≥ k]: True when X’s current lower bound is at least k.
  • UB literal [X ≤ k]: True when X’s current upper bound is at most k.

Each distinct (variable, k) pair maps to a unique Boolean variable in the SAT solver. Raising X’s lower bound to k is equivalent to setting [X ≥ k] = True on the trail.

For a Boolean variable like TeachA_R1_T3 ∈ {0, 1}, there are only two literals: [X ≥ 1] (which equals “X is True”) and [X ≤ 0] (which equals “X is False”). For a larger integer like RoomSlot ∈ {0, 1, 2, 3, 4}, there are eight distinct bound literals — one per inequality boundary.

Show the full encoding for RoomSlot ∈ {0,1,2,3,4}
LiteralMeaningTrue when…
[RoomSlot ≥ 1]At least one teacherlb ≥ 1
[RoomSlot ≥ 2]At least two teacherslb ≥ 2
[RoomSlot ≥ 3]At least three teacherslb ≥ 3
[RoomSlot ≥ 4]At least four teacherslb ≥ 4
[RoomSlot ≤ 0]Zero teachers (empty room)ub ≤ 0
[RoomSlot ≤ 1]At most one teacherub ≤ 1
[RoomSlot ≤ 2]At most two teachersub ≤ 2
[RoomSlot ≤ 3]At most three teachersub ≤ 3

When a CP propagator raises RoomSlot’s lower bound from 0 to 2, it posts the literals [RoomSlot ≥ 1] = True and [RoomSlot ≥ 2] = True to the trail — two Boolean trail entries, each with a reason clause. The solver’s Boolean propagation then checks whether any clause watches those newly-true literals.

These literals appear on the trail interleaved with ordinary Boolean literals from original clauses and decisions. A CDCL propagation step cannot tell them apart: a literal is a literal, a reason clause is a reason clause, and the implication graph reconstruction machinery works identically.

Apply a lower-bound reduction to an integer variable and watch the order literals (x ≥ k) it implies land on the shared trail.

Common misconception

CP-SAT maintains two separate data structures: one for SAT Boolean variables and one for integer domain variables.

What's actually true

There is one trail, one watch-list index, and one clause database. Integer variables are represented as sets of Boolean literals via the lb/ub encoding. When a CP propagator updates a domain, it appends Boolean literals to the same trail the CDCL engine reads. The two layers are not parallel — they are stacked, with the integer encoding as the bridge.

Check your understanding

A CP propagator raises variable X's lower bound from 3 to 5 in a single Propagate() call. How many trail entries does this create, and what are they?

Propagation Levels and Quiescence

The solver loop alternates layers until neither can make progress. But what does “quiescence” mean precisely, and what happens to both layers during conflict resolution?

Quiescence is the state where:

  1. CDCL Boolean propagation has advanced qhead to the end of the trail — every trail entry has been checked for its effect on watching clauses.
  2. Every registered CP propagator has been called and none has posted a new literal or clause.

Only at full quiescence does the solver invoke its branching heuristic (VSIDS from the previous lesson) to choose the next decision variable.

Show what happens during backjumping across both layers

When conflict analysis derives a new learned clause and a backjump target level kk (the asserting level from the first-UIP cut), the solver:

  1. Slices the trail back to level_start[k+1] — unwinding both Boolean decisions, CDCL-propagated literals, and CP-propagated integer literals.
  2. Re-registers watchers for any clause (original, learned, or lazy) whose watched literal was in the unwound segment, as described in the previous lesson’s two-watched-literal section.
  3. Calls Untrail(trail_index) on every registered CP propagator — giving each propagator a chance to roll back any internal state (e.g., cached domain snapshots) that it maintained for levels above kk.
  4. Posts the new learned clause as a unit clause — its asserting literal is the one literal in the clause that is not yet assigned (all others are False at level kk). This literal is propagated by CDCL immediately, and the loop resumes from CDCL propagation.

The critical property: lazy clauses persist across backjumps just like learned clauses. They remain in the clause database with their watch-list entries. If the same conflict pattern recurs on a future branch, the lazy clause will fire again — this time from the clause database, without the CP propagator needing to regenerate it. Lazy clause generation is thus a kind of on-demand clause learning: the CP propagator teaches the CDCL engine one consequence at a time, and the CDCL engine caches and reuses what it learns.

[Handbook of Satisfiability (2nd ed.), Ch. 23: Satisfiability and Constraint Programming]
CDCL Layer CP Propagator Layer
What triggers it New trail entry falsifies a watched literal in a Boolean clauseNew trail entry reduces an integer variable in a propagator's watch-set
What it produces Unit propagation: new Boolean literal + reason clause on trailLazy clause generation: new lazy clause posted to DB; fires as unit clause
Conflict handling Detects conflict when clause becomes empty; triggers conflict analysisDetects domain wipeout or logical contradiction; posts empty/unit reason clause; triggers conflict analysis
Backjump behaviour Trail sliced; watcher re-registration on unwound literalsUntrail() called; internal state rolled back; watch-sets remain registered
Source file sat/sat_solver.cc (PropagationLoop)sat/constraint_programming_propagator.cc (GenericLiteralWatcher)
How the two layers compare across key operations

Check your understanding

After a backjump to level 2, a lazy clause that was generated at level 4 is still in the clause database. Can it fire on the new branch at level 2?

Propagator Architecture: Check Your UnderstandingQ 1 / 5

A school-timetabling model has 50 CP propagators registered. The solver makes a decision that touches one integer variable. How many CP propagators are called in the next CP propagation round?