Optimization in CP-SAT: Objective Functions and the LP Relaxation
CP-SAT encodes minimize/maximize objectives as weighted Boolean literals, solves an LP relaxation internally via Glop to obtain a dual bound, and uses that bound to prune entire subtrees — all without exposing a separate MIP solver to the user.
- Estimated time
- ~16 min
- Difficulty
- advanced
- Sources
- 5 sources
In the previous lesson on Lazy Clause Generation, we traced how CP-SAT’s cumulative propagator fired, produced an explanation clause — a conjunction of bound literals — and handed it to CDCL for learning. That mechanism makes CP-SAT fast at satisfying constraints. This lesson is about what happens when you add an objective: minimize or maximize some linear expression over those same integer variables.
A pure SAT solver can only say “yes” or “no.” A pure CP solver can minimize by repeatedly asking “is there a solution better than my current best?” — but it has no way to know how much better things could get. CP-SAT does something more clever: it keeps a continuously-updated dual bound — a proven lower limit on how small the objective can ever be — and uses it to chop off entire subtrees of search before entering them.
How CP-SAT Encodes an Objective
Recall from the CP-SAT Data Structures lesson that every integer variable in the solver is backed by a set of Boolean literals of the form x ≥ k — one literal per step of the domain. When the CP-SAT model includes a line such as:
model.Minimize(3 * x + 5 * y + 2 * z)the solver does not keep 3*x + 5*y + 2*z as a symbolic expression. It encodes the objective as a weighted sum of those same indicator literals.
[OR-Tools CP-SAT source: sat/integer.h]
Concretely, if x has domain [0, 5], the literals are (x ≥ 1), (x ≥ 2), …, (x ≥ 5). Each one carries weight 3 (the coefficient of x). The objective value at any assignment is:
This is an order encoding — the value of an integer variable is the count of x ≥ k literals that are true, weighted by the objective coefficient.
CP-SAT introduces a dedicated integer variable — ObjectiveVar — whose domain is [lower_bound, upper_bound] of the objective expression. Every propagation that tightens the objective bounds (either through domain filtering or through the LP relaxation) translates to raising the lower bound of ObjectiveVar. When a new incumbent is found, the upper bound of ObjectiveVar is lowered, which in turn forces ObjectiveVar ≥ current_best as a clause — preventing the solver from re-exploring solutions no better than the current best.
Show how ObjectiveVar interacts with the CDCL trail
When a new incumbent solution of value V is found, the solver calls:
integer_trail->Enqueue(
IntegerLiteral::GreaterOrEqual(objective_var, V),
/*reason=*/{} // no reason needed — it is a new decision
);From that point on, any partial assignment that would produce an objective value less than V is immediately pruned by unit propagation through the objective’s indicator literals. The CDCL engine does not need a special “objective branch” — it just sees the ObjectiveVar ≥ V literal and propagates it like any other bound.
This is why CP-SAT has no explicit “branch-and-bound layer” visible in the source: objective pruning is absorbed into the standard propagation loop using the same LCG machinery described in the previous lesson.
Check your understanding
If a variable x has domain [0, 4] and objective coefficient 7, how many indicator literals does it contribute to the objective encoding, and what is each one's weight?
The LP Relaxation and the Dual Bound
Here is the question that distinguishes CP-SAT from a plain CP solver: how low can the objective possibly go, given the constraints?
A CP propagator can tighten individual domain bounds, but it cannot answer that question globally without exploring the entire search tree. The LP relaxation can.
The LP relaxation of a MIP problem is the same problem with the integrality requirements dropped — variables are allowed to take any real value in their domain. The LP relaxation is:
- Always feasible if the integer problem is feasible (real values are at least as flexible as integer ones).
- Always at least as optimal as the integer problem — its optimal value is a lower bound (for minimization) on the integer optimum.
- Solvable in polynomial time by the simplex method.
The value of the LP relaxation at its optimum. Because the LP relaxation is a relaxation of the integer problem, its optimum is a proven lower limit on how small the true integer objective can be. Any subtree of the branch-and-bound tree where the LP bound exceeds the current incumbent can be pruned immediately — no integer solution in that subtree can improve on what we already have.
Inside CP-SAT, the LP relaxation is solved by Glop, Google’s own primal simplex solver, which is compiled directly into the OR-Tools binary. [OR-Tools CP-SAT source: sat/linear_programming_constraint.cc] Glop is invoked as a propagator — it slots into the same propagator registry that the cumulative and AtMostOne constraints use. At each node of the search tree, after CDCL makes a decision and runs unit propagation, the Glop propagator fires: it solves the LP restricted to the current partial assignment’s domain bounds, then:
- Uses the LP optimum to tighten
ObjectiveVar’s lower bound. - Uses the LP dual variables (shadow prices) to derive reduced cost bounds on individual variables, tightening their domains via LCG explanations.
This last point is the architectural elegance: the LP’s output feeds directly back into the LCG mechanism. The explanation for “variable x cannot exceed 3 in this subtree” is not an arbitrary assertion — it is derived from the LP dual solution and expressed as a conjunction of domain bound literals, exactly as a CP propagator’s Explain() would do.
Common misconception
CP-SAT is just a SAT solver with LP bolted on the side.
What's actually true
The LP is not a post-processing step or a separate solver. It is a first-class propagator in the same registry as cumulative and AtMostOne. Its dual solution feeds directly into LCG to produce bound tightening clauses. The boundary between “LP” and “CP” is an implementation detail — to the CDCL engine, every bound update looks identical regardless of whether it came from a CP propagator or from Glop.
Check your understanding
Why is the LP relaxation optimum always a valid lower bound on the integer optimum (for minimization)?
How the Dual Bound Drives Pruning
The dual bound’s power becomes concrete in the branch-and-bound tree. Consider a minimization problem where the current best integer solution (incumbent) has objective value V*. At any node of the search tree, after Glop solves the LP for that node’s restricted domain:
- If
LP_bound ≥ V*: this entire subtree is pruned. Every integer solution in it has objective value at leastLP_bound ≥ V*, so none can improve on the incumbent. The solver backtracks immediately. - If
LP_bound < V*: the subtree might contain a better solution. The solver branches.
graph TD root["Root: LP_bound=10\nIncumbent=∞"]:::node n1["x≤2: LP=12\n≥ incumbent 15 → PRUNE"]:::pruned n2["x≥3: LP=10\n< incumbent 15"]:::node n3["y≤1: LP=14\n≥ incumbent 15 → PRUNE"]:::pruned n4["y≥2: LP=10\nNew incumbent=13"]:::node n5["z≤0: LP=13\n= incumbent → PRUNE"]:::pruned n6["z≥1: LP=10\nNew incumbent=11"]:::node root --> n1 root --> n2 n2 --> n3 n2 --> n4 n4 --> n5 n4 --> n6 classDef node fill:#1e3a5f,color:#cbd5e1,stroke:#334155 classDef pruned fill:#7f1d1d,color:#fca5a5,stroke:#dc2626
The key insight — and the reason CP-SAT’s LP integration is an architectural decision, not an afterthought — is that the dual bound propagates into domain tightening before branching. When Glop reports that the LP optimum restricts x ≤ 3.7, the solver tightens ub(x) to 3 via an LCG explanation. That tightening fires unit propagation through the watch list (as seen in the CP-SAT Data Structures lesson), which may cascade into further propagations by other constraints — all before the next branch decision.
Show the reduced-cost bound tightening formula
For a minimization objective with coefficient c_j for variable x_j, the LP dual gives a reduced cost r_j. The reduced cost bound tightening says:
where x_j^{LP*} is the LP optimum value of x_j and V* is the incumbent. If this is tighter than the current upper bound, the LP propagator calls EnqueueWithLazyReason(ub_lit(x_j, new_ub), ...) — producing a lazy explanation whose Explain() callback will reconstruct the LP dual values on demand.
[OR-Tools CP-SAT source: sat/linear_programming_constraint.cc]
This is exactly the explanation callback pattern from the previous lesson. The LP propagator is just another LazyReasonInterface implementation.
| Mechanism | What it provides | When it fires | |
|---|---|---|---|
| CP propagators | Domain filtering (AC/bounds) | LCG clauses with semantic explanations | After each decision, round-robin |
| CDCL + unit prop | Non-chronological backjumping | Clause learning from explanations | Continuously — 2-watched literals |
| LP relaxation (Glop) | Global dual bound + reduced-cost tightening | LP-derived LCG explanations | Periodically — every N decisions |
| ObjectiveVar | Encodes incumbent as a clause | Immediate pruning via unit propagation | On every new incumbent |
Check your understanding
At a node where the LP dual bound equals 18 and the current incumbent is 17 (minimization), what does CP-SAT do?
Cuts Injected by Glop: Closing the Integrality Gap
The LP relaxation’s dual bound is only as tight as the LP polytope is close to the integer hull — the smallest convex region that contains exactly the integer feasible points. If the LP polytope has large fractional corners, the dual bound is loose and pruning is weak.
Cutting planes close this gap by adding valid linear inequalities that cut off fractional regions of the LP polytope without removing any integer points. Each cut is a linear constraint that:
- Is satisfied by every integer feasible point (it is valid).
- Is violated by the current LP optimum (it actually cuts something off).
CP-SAT’s LP propagator (linear_programming_constraint.cc) injects several families of cuts:
[OR-Tools CP-SAT source: sat/linear_programming_constraint.cc]
- Gomory mixed-integer cuts — derived directly from the LP simplex tableau. When the LP optimum has a fractional variable
x_j = f + ⌊f⌋, the Gomory procedure generates a cut by rounding the tableau row containingx_j. These cuts are guaranteed to eliminate the current fractional LP optimum. [Outline of an Algorithm for Integer Solutions to Linear Programs] - Cover cuts — for 0-1 knapsack sub-problems embedded in the LP. A cover is a set of binary variables whose sum of coefficients exceeds the knapsack capacity; at most one fewer than all of them can be 1.
- Clique cuts — for Boolean variables with at-most-one relationships. If a set of literals forms a clique in the conflict graph (any two are mutually exclusive), their sum is at most 1. These are cheap to generate and extremely effective for scheduling problems.
Each cut generated is added to the LP’s constraint matrix and also stored as a lazy clause in the CDCL clause database — so if the cut’s condition is later triggered purely by unit propagation, the LP does not need to be re-solved.
Glop vs GLPK
Early documentation of OR-Tools described CP-SAT as using GLPK for LP. The current codebase uses Glop, Google’s own primal simplex implementation, which is tightly integrated with the OR-Tools type system and does not require a separate library dependency. [CP-SAT: Combining Constraint Programming and SAT with LP]
Common misconception
Adding more cuts always makes CP-SAT faster.
What's actually true
Cuts improve the dual bound and prune more subtrees, but they also increase the LP solve time at each node. CP-SAT’s heuristics limit cut generation to high-quality cuts (large violation, small support) and skip cutting if the LP time budget is being consumed. The correct framing: cuts are worthwhile when the improvement to the dual bound outweighs the extra LP overhead — a trade-off the solver manages automatically via its internal cut scoring.
Check your understanding
A Gomory cut is generated from the LP tableau when the current LP optimum is fractional. What does it guarantee?
Check your understanding: Optimization in CP-SATQ 1 / 5
Why does CP-SAT encode the objective as a weighted sum of Boolean indicator literals rather than keeping it as a symbolic expression?