Lazy Clause Generation: CP-SAT's Hybrid Core

CP-SAT combines CP propagators with CDCL by generating SAT clauses on demand — only when a propagator fires — so learned clauses capture high-level domain reasoning without the exponential upfront encoding cost of pure SAT.

Estimated time
~14 min
Difficulty
advanced
Sources
5 sources

In the previous lesson, we traced CP-SAT’s propagator registry and interleaved solver loop — following the AtMostOne constraint as it fired, posted a lazy clause, and let CDCL do the backjumping. That example introduced the idea of lazy clause generation; this lesson is about the mechanism.

A scheduling solver that pre-encodes every possible constraint interaction needs exponentially many clauses before search starts. CP-SAT generates those clauses one at a time, only when a propagator actually fires — and the resulting explanation is tight enough for CDCL to learn from it.

What an Explanation Is

In pure CP, when a propagator removes a value from a domain, it just does it — no record is kept of why. If search backtracks, the same removal is re-discovered the same way at every subsequent visit. There is no memory across branches.

Lazy Clause Generation (LCG) breaks that cycle. Every domain reduction must come with an explanation: a minimal set of currently-true literals that together imply the reduction. That explanation is immediately turned into a SAT clause and handed to the CDCL engine. Now if the same sub-configuration appears on a later branch, the clause fires via unit propagation — no propagator call needed.

Explanation (LCG) def.

Given a propagated domain reduction — e.g. the lower bound of integer variable xx is raised to kk — the explanation is a conjunction of literals 12n\ell_1 \wedge \ell_2 \wedge \cdots \wedge \ell_n such that those literals being true in the current assignment implies lb(x)k\text{lb}(x) \geq k. The negation forms a clause: (¬1¬2¬n(xk))(\neg\ell_1 \vee \neg\ell_2 \vee \cdots \vee \neg\ell_n \vee (x \geq k)).

The clause has exactly one positive literal — the consequence — and one negative literal per reason. [Explaining Propagators in CP-SAT] This matches the structure of the asserting clauses you saw in the CDCL lesson: a clause with exactly one literal at the current decision level, enabling unit propagation immediately after backjumping.

Show the integer literal encoding from sat/integer.h

CP-SAT encodes integer domain facts as Boolean literals via IntegerEncoder. For variable xx with domain [lb,ub][lb, ub]:

  • The literal lb_lit(x, k) represents xkx \geq k — true when the lower bound is at least kk.
  • The literal ub_lit(x, k) represents xkx \leq k — true when the upper bound is at most kk.

These are not new variables — they are just named handles into the SAT layer’s trail. When the propagator calls integer_trail->Enqueue(IntegerLiteral::GreaterOrEqual(x, k), reason_literals), it:

  1. Pushes the new bound onto the integer trail with its decision level.
  2. Tags the trail entry with reason_literals so the explanation can be reconstructed on demand during conflict analysis.
  3. Enqueues the corresponding Boolean literal lb_lit(x,k) into the SAT propagation queue.

The critical word is “on demand”. The clause is not immediately materialized — the reason literals are stored as a lazy callback. Only when CDCL’s conflict analysis walks the implication graph to the lb_lit(x,k) node does it call back into the propagator to get the actual literals. This is the laziness.

Check your understanding

Why does an explanation clause have exactly one positive literal?

The Explanation Callback Pattern in OR-Tools C++

The OR-Tools C++ source defines the explanation contract in sat/integer.h. Every propagator that calls integer_trail->Enqueue(...) to raise a lower bound or lower an upper bound passes a reason object — either a direct vector of literals, or a callback of type LazyReasonFunction. [OR-Tools CP-SAT source: sat/integer.h]

sat/integer.h The two explanation callback signatures in sat/integer.h
// Explanation via a pre-computed literal vector (eager — rare in practice)
bool Enqueue(IntegerLiteral i_lit,
             absl::Span<const Literal> literal_reason,
             absl::Span<const IntegerLiteral> integer_reason);

// Explanation via a lazy callback (preferred — most propagators use this)
bool EnqueueWithLazyReason(IntegerLiteral i_lit,
                           int id,
                           IntegerValue propagation_slack,
                           LazyReasonInterface* lazy_reason);

The lazy form stores only (id, propagation_slack) — a lightweight token — on the trail. When CDCL conflict analysis reaches this trail entry, it calls:

sat/integer.h LazyReasonInterface in sat/integer.h
class LazyReasonInterface {
 public:
  virtual void Explain(int id,
                       IntegerValue propagation_slack,
                       IntegerVariable var_to_explain,
                       std::vector<Literal>* literals,
                       std::vector<IntegerLiteral>* integer_literals) = 0;
};

Explain() receives the same id and slack token it stored, plus the variable whose bound it is explaining, and fills literals and integer_literals with the reason set. The propagator’s Explain() implementation reconstructs the conflict from its own internal state at that point — which is valid because CDCL conflict analysis only runs while the trail is still at or above the level of the propagation being explained.

Analogy — detective's notes is like lazy explanation

Think of the propagator as a detective who writes “I deduced this because: see file #42” rather than transcribing the full reasoning at the time. When a judge (CDCL conflict analysis) later demands the proof, the detective opens file #42 and reads it aloud. The filing happens at inference time; the reading happens only if conflict analysis actually needs it.

The linear propagator in sat/linear_propagator.cc is the canonical example. [OR-Tools CP-SAT source: sat/linear_propagator.cc] When propagating a constraint of the form a1x1+a2x2++anxnba_1 x_1 + a_2 x_2 + \cdots + a_n x_n \leq b, and it determines that xjx_j‘s upper bound must drop, it stores an id pointing into the constraint’s coefficient array. On Explain(), it recomputes which other variables’ current bounds account for the slack that forced xjx_j‘s reduction.

Check your understanding

What does 'propagation_slack' encode in the lazy reason token?

Building an Explanation: The Cumulative Constraint

The cumulative constraint — “a set of jobs, each needing some resource capacity, must not exceed total capacity at any time” — is the core of teacher/room/time scheduling. It is also the best illustration of what a CP explanation looks like in practice.

Suppose jobs A and B are both assigned to slots [2, 5) (both need the one machine), and job C has current lower bound lb(C) = 3. The cumulative propagator detects that [2, 5) is over-subscribed — capacity 1, but two jobs overlap there. It concludes: job C cannot start before slot 5 (because slots 2–4 are already fully booked). It calls EnqueueWithLazyReason(lb_lit(C, 5), id, slack).

When CDCL later asks for the explanation, Explain() returns:

(startA2)(startA+3>2)(startB2)(startB+3>2)startC5(\text{start}_A \geq 2) \wedge (\text{start}_A + 3 > 2) \wedge (\text{start}_B \geq 2) \wedge (\text{start}_B + 3 > 2) \Rightarrow \text{start}_C \geq 5

Negated as a clause: ¬(startA2)¬(startA+3>2)¬(startB2)¬(startB+3>2)(startC5)\neg(\text{start}_A \geq 2) \vee \neg(\text{start}_A + 3 > 2) \vee \neg(\text{start}_B \geq 2) \vee \neg(\text{start}_B + 3 > 2) \vee (\text{start}_C \geq 5).

This clause is exactly as tight as the propagation demands — it includes only the bounds of A and B that matter for the conflict window, not every constraint touching those variables. A looser explanation (including irrelevant facts) would still be sound, but would produce a clause that fires in fewer future situations, wasting the learning opportunity.

The widget below lets you manipulate a 3-job cumulative scenario and watch the explanation clause being built step by step, mirroring exactly what Explain() does in C++.

Set the job bounds and watch LCG build the explanation clause — the minimal set of bound literals that justify the propagation.

Common misconception

A tight explanation is always better than a loose one.

What's actually true

Tightness is a trade-off. A maximally tight explanation takes more CPU to compute (the propagator must analyse the constraint carefully) and produces a more specific clause (fires in fewer situations). A looser explanation is faster to compute and produces a more general clause. OR-Tools propagators often compute a sufficient explanation rather than the minimal one — good enough to enable non-chronological backjumping without exhausting every millisecond on clause tightening.

Check your understanding

In the cumulative explanation above, why does the clause NOT include literals about job C's duration?

How Explanation Clauses Feed CDCL Conflict Analysis

Recall from the CDCL lesson: when CDCL hits a conflict (a variable forced to both true and false), it walks the implication graph backwards from the conflict node, applying the first-UIP cut to find the asserting clause. [Lazy Clause Generation: Combining the Power of SAT and CP for Solving Long-Horizon Hybrid Problems]

In CP-SAT, the implication graph includes both SAT implications (unit propagation via two-watched literals) and CP implications (bounds tightened by propagators). The boundary between them is invisible to conflict analysis — every trail entry has a reason, whether that reason came from a SAT clause or from a CP propagator’s Explain() callback.

flowchart TD
  D1["Decision: start_A = 2"]:::decision
  D2["Decision: start_B = 2"]:::decision
  P1["Propagation via 2WL\n(some other clause)"]:::sat
  CP1["CP: Cumulative fires\nlb(C) ≥ 5"]:::cp
  CP2["CP: AtMostOne fires\n¬assign_C_slot3"]:::cp
  CONF["Conflict: C needs slot 3\nbut lb(C) ≥ 5"]:::conflict
  D1 --> CP1
  D2 --> CP1
  P1 --> CP2
  CP1 --> CONF
  CP2 --> CONF
  classDef decision fill:#7c3aed,color:#fff,stroke:none
  classDef sat fill:#0e7490,color:#fff,stroke:none
  classDef cp fill:#059669,color:#fff,stroke:none
  classDef conflict fill:#dc2626,color:#fff,stroke:none
CDCL conflict analysis crosses the SAT–CP boundary seamlessly

When conflict analysis reaches lb(C) ≥ 5 (a CP-propagated node), it calls Explain() on the cumulative constraint. The returned literals become the predecessors of that node in the implication graph. Analysis continues through them as if they had always been explicit. The resulting learned clause may reference a mixture of SAT literals and integer bound literals — that is fine, because both live in the same Boolean trail.

Show the trail walk during conflict analysis

The solver’s trail at conflict time might look like:

Level 3: [start_A = 2]  ← decision
Level 3: [start_B = 2]  ← decision
Level 3: lb(C) ≥ 5       ← cumulative propagator, lazy reason id=17
Level 3: ¬assign_C_sl3  ← AtMostOne propagator (from prior lesson example)
  → CONFLICT: C must be in slot 3, but lb(C)≥5 rules that out

Conflict analysis walks backwards from the conflict. It hits lb(C) ≥ 5, calls Explain(id=17, ...), and gets back {start_A ≥ 2, start_A + 3 > 2, start_B ≥ 2, start_B + 3 > 2}. It substitutes those literals for lb(C) ≥ 5 in the graph. It continues walking backwards until it finds the first-UIP cut — a literal that is at the current decision level and whose negation would make the clause asserting.

The learned clause is added to the clause database. The solver backjumps non-chronologically to the level where the clause becomes unit. The literal lb(C) ≥ 5 (or its tighter version from the cut) fires immediately as a unit propagation.

Check your understanding

What happens during conflict analysis when it encounters a CP-propagated trail entry (not a SAT-propagated one)?

Why LCG Beats Pure CP and Pure SAT on Scheduling

This is the payoff. The widget below models a small scheduling problem under all three regimes — vary the number of jobs, time slots, and constraint tightness to see backtrack counts and clause counts diverge.

Compare pure CP, pure SAT, and LCG on the same scheduling problem — watch the backtrack and learned-clause counts diverge.
Pure CP Pure SAT (eager) LCG (CP-SAT)
Backtracking Chronological onlyNon-chronological (CDCL)Non-chronological (CDCL)
Clause learning NoneYes — from SAT unit propYes — from CP explanations
Upfront clause count 0O(n² × horizon)0 (generated lazily)
Propagation quality AC / bounds-consistentUnit propagation onlyAC + unit prop
Clause quality N/AFlat — ignores CP structureHigh — encodes CP reasoning
Why neither pure approach matches LCG on scheduling

Pure CP re-solves every sub-problem from scratch. When it backtracks from a dead end caused by slots 2–5 being overloaded, it has no record of why it failed — the next branch may hit exactly the same dead end. Arc-consistency propagation is strong but local; it cannot reuse knowledge across backtrack points.

Pure SAT encodes everything eagerly. Before search even starts, it must generate clauses for every possible bound relationship — for nn jobs over a horizon of HH slots, that is O(n2H)O(n^2 H) clauses covering constraints that may never be relevant. The CDCL engine then churns through a bloated database where most clauses are never queried.

LCG generates clauses only for the propagations that actually fire. In practice, on a 10-job scheduling problem, roughly 80–95% of the clauses a pure SAT encoding would pre-generate are never needed. The ones that are generated carry CP-level reasoning — they encode statements like “if A occupies slots 2–5 and B occupies slots 3–6, then C cannot start before slot 6” — far more semantically rich than the flat pairwise exclusions a SAT encoder would produce. [Lazy Clause Generation: Combining the Power of SAT and CP for Solving Long-Horizon Hybrid Problems]

Check your understanding

A pure SAT encoding of a cumulative constraint with 8 jobs and 20 time slots needs roughly how many upfront clauses, compared to LCG on the same problem?


Check your understanding: Lazy Clause GenerationQ 1 / 5

What is stored on the integer trail when EnqueueWithLazyReason() is called?