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.
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.
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:#d1d5dbThe 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.
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]
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:
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.
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.
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}
| Literal | Meaning | True when… |
|---|---|---|
[RoomSlot ≥ 1] | At least one teacher | lb ≥ 1 |
[RoomSlot ≥ 2] | At least two teachers | lb ≥ 2 |
[RoomSlot ≥ 3] | At least three teachers | lb ≥ 3 |
[RoomSlot ≥ 4] | At least four teachers | lb ≥ 4 |
[RoomSlot ≤ 0] | Zero teachers (empty room) | ub ≤ 0 |
[RoomSlot ≤ 1] | At most one teacher | ub ≤ 1 |
[RoomSlot ≤ 2] | At most two teachers | ub ≤ 2 |
[RoomSlot ≤ 3] | At most three teachers | ub ≤ 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.
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:
- CDCL Boolean propagation has advanced
qheadto the end of the trail — every trail entry has been checked for its effect on watching clauses. - 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 (the asserting level from the first-UIP cut), the solver:
- Slices the trail back to
level_start[k+1]— unwinding both Boolean decisions, CDCL-propagated literals, and CP-propagated integer literals. - 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.
- 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 . - 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 ). 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.
| CDCL Layer | CP Propagator Layer | |
|---|---|---|
| What triggers it | New trail entry falsifies a watched literal in a Boolean clause | New trail entry reduces an integer variable in a propagator's watch-set |
| What it produces | Unit propagation: new Boolean literal + reason clause on trail | Lazy clause generation: new lazy clause posted to DB; fires as unit clause |
| Conflict handling | Detects conflict when clause becomes empty; triggers conflict analysis | Detects domain wipeout or logical contradiction; posts empty/unit reason clause; triggers conflict analysis |
| Backjump behaviour | Trail sliced; watcher re-registration on unwound literals | Untrail() called; internal state rolled back; watch-sets remain registered |
| Source file | sat/sat_solver.cc (PropagationLoop) | sat/constraint_programming_propagator.cc (GenericLiteralWatcher) |
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?