The Python API Layer: Protobuf, CP-SAT Model, and Solver Parameters

The Python CpModel is a thin wrapper around a Protobuf message that serialises your constraints into bytes, ships them across a language boundary to C++, and gets a CpSolverResponse back. Every SolverParameter you set is a direct knob into the CDCL+CP+LP machinery you've already studied.

Estimated time
~18 min
Difficulty
advanced
Sources
6 sources

Coming from the previous lesson?

In “Optimization in CP-SAT: Objective Functions and the LP Relaxation” we traced how model.Minimize(3*x + 5*y + 2*z) encodes the objective as weighted Boolean indicator literals, how Glop fires as a propagator to derive a dual lower bound, and how a new incumbent lowers ObjectiveVar’s upper bound — triggering unit propagation through the two-watched-literal machinery to prune every sub-optimal branch automatically. This lesson pulls back to the Python surface and asks: what exactly happens between the line solver.Solve(model) and the moment those indicator literals appear on the Boolean trail?

You write ten lines of Python, call solver.Solve(model), and two seconds later the solver reports the optimal room assignment. But the OR-Tools solver is written in C++. Your Python objects don’t cross the language boundary — only bytes do. Every constraint you added, every variable you declared, every coefficient in your objective was silently assembled into a binary Protobuf message the moment you called the API. Understanding that message — and the parameters you can attach to it — gives you direct access to the CDCL, CP, and LP knobs you’ve studied throughout this course.

CpModel: A Thin Protobuf Wrapper

Open ortools/sat/python/cp_model.py and you find that CpModel has almost no logic of its own. Its constructor creates one object:

cp_model.py What CpModel.__init__ actually does (ortools/sat/python/cp_model.py)
class CpModel:
    def __init__(self):
        self.__model = cp_model_pb2.CpModelProto()

cp_model_pb2.CpModelProto is the Python class auto-generated from ortools/sat/cp_model.proto. Every method you call on CpModelNewIntVar, AddAllDifferent, Minimize — appends to fields of that single proto object.

[OR-Tools Python bindings: ortools/sat/python/cp_model.py]

Tracing NewIntVar into the proto

model = CpModel()
x = model.NewIntVar(0, 4, "x")

Under the hood, NewIntVar does roughly:

def NewIntVar(self, lb, ub, name):
    index = len(self.__model.variables)
    var_proto = self.__model.variables.add()     # append to repeated field
    var_proto.domain.extend([lb, ub])            # [min, max] domain encoding
    var_proto.name = name
    return IntVar(self.__model, index, name)     # thin handle, not a value

The returned IntVar is just an integer index plus a reference back to the model proto — it holds no domain state itself. When you later pass x to AddAllDifferent or Minimize, those methods resolve it to its index and write that index into the appropriate proto field.

CpModelProto def.

The Protocol Buffer message defined in ortools/sat/cp_model.proto that is the complete, serialisable representation of a CP-SAT model. It contains three top-level repeated fields: variables (integer variable domains), constraints (typed constraint messages), and objective (coefficients + offset for minimisation/maximisation). The Python CpModel class is a builder that populates this proto; all solving logic is in C++.

[OR-Tools CP-SAT source: ortools/sat/cp_model.proto]

Check your understanding

What does the Python IntVar object actually store?

Protobuf Serialisation: The Language Boundary

When you call solver.Solve(model), the very first step is:

response_proto = pywrap_sat.SolveWithParameters(
    model.Proto().SerializeToString(),   # bytes
    parameters.SerializeToString(),      # bytes
)

model.Proto() returns the CpModelProto directly. .SerializeToString() is the standard Protobuf API — it encodes the proto as a compact binary byte string. Those bytes are handed to a C extension (pywrap_sat) that deserialises them back into a C++ CpModelProto and calls SolveCpModel().

sequenceDiagram
  participant PY as Python
  participant PB as Protobuf (bytes)
  participant CC as C++ SolveCpModel

  PY->>PB: model.Proto().SerializeToString()
  PB->>CC: deserialise → CpModelProto&
  Note over CC: Presolve → Trail → Propagators → CDCL+LP
  CC->>PB: CpSolverResponse.SerializeToString()
  PB->>PY: deserialise → CpSolverResponse
The Python → C++ → Python round-trip. Only bytes cross the language boundary.
[Protocol Buffers Language Guide (proto3)]

Why Protobuf instead of a direct C-API with Python ctypes or shared memory?

Property Protobuf Direct ctypes / shared memory
Language portability Same .proto file generates Python, Java, Go, C# bindingsMust re-implement the layout in every target language
Schema versioning Fields are numbered; old clients skip unknown fields gracefullyLayout changes break binary compatibility immediately
Serialisation overhead One allocation + memcpy per Solve call — negligible vs. solve timeZero — but adds coupling and pointer-ownership complexity
Debugging model.Proto() is inspectable at any point in Python with standard proto toolsRequires C-level debugger to inspect shared state
Why Protobuf is the CP-SAT wire format
Show a minimal school-timetabling CpModelProto in text form

After building a model with 2 teachers, 2 rooms, and 2 time slots (8 Boolean assignment variables), print(model.Proto()) produces something like:

variables {
  domain: [0, 1]   # Boolean: teacher A, room 1, slot 1
  name: "assign_A_R1_T1"
}
# ... 7 more variable entries ...
constraints {
  exactly_one { literals: [0, 1] }   # Teacher A in exactly one slot
}
constraints {
  exactly_one { literals: [2, 3] }   # Teacher B in exactly one slot
}
# ... more constraints ...
objective {
  vars: [0, 1, 2, 3, 4, 5, 6, 7]
  coeffs: [10, 10, 10, 10, 10, 10, 10, 10]
  offset: 0
}

Every field maps directly to a constraint type in cp_model.proto: ExactlyOneConstraintProto, LinearConstraintProto, AllDifferentConstraintProto, and so on. The C++ SolveCpModel() function iterates these and instantiates the corresponding CpPropagator objects — the same propagators you met in “The CP-SAT Propagator Registry and Solver Loop.”

Check your understanding

Why does serialising the model to bytes not lose information about variable names?

Inside SolveCpModel: What C++ Does with the Proto

Once SolveCpModel() receives the deserialised CpModelProto, it runs a pipeline whose stages map directly onto the machinery from earlier lessons. [OR-Tools CP-SAT source: ortools/sat/cp_model_solver.cc (SolveCpModel)]

flowchart TD
  A[CpModelProto&] --> B[Presolve
variable elimination
constraint simplification
symmetry detection]
  B --> C[Model Expansion
build SatSolver trail
instantiate CpPropagators
register GenericLiteralWatcher]
  C --> D[Portfolio Worker Setup
N independent SatSolvers
shared clause database subset
or independent DBs]
  D --> E[CDCL + CP + LP Solve Loop
from the Propagator Architecture lesson]
  E --> F[Collect best response
fill CpSolverResponse]
  F --> G[CpSolverResponse&]
  style B fill:#1e3a5f,color:#cbd5e1
  style C fill:#4c1d95,color:#ddd6fe
  style D fill:#065f46,color:#a7f3d0
  style E fill:#7f1d1d,color:#fca5a5
  style F fill:#374151,color:#d1d5db
The C++ pipeline inside SolveCpModel, from proto to CpSolverResponse

Presolve is the most opaque phase. The solver applies a battery of rewriting rules:

  • Domain propagation at level 0 — run all propagators before any branch decision. Constraints that become unit implications are resolved immediately.
  • Variable elimination — if a variable appears in only a few constraints, substitute it away algebraically, reducing the model size.
  • Constraint simplification — detect tautologies (always-true constraints) and contradictions; fold constants.
  • Symmetry detection — find permutations of variables that leave the constraint set invariant; add symmetry-breaking clauses so the solver explores only one representative of each symmetric class.

After presolve, the surviving proto fields are expanded into the C++ data structures you know: the assignment trail, the two-watched-literal index, and the CpPropagator registry (including Glop as the LP propagator, as discussed in “Optimization in CP-SAT”).

Common misconception

solver.Solve(model) solves exactly the model you built — no rewriting happens.

What's actually true

Presolve can dramatically change the model. Variables may be eliminated, domains tightened, and symmetry-breaking constraints added — all before the first CDCL decision. The CpSolverResponse you receive reflects the original variable indices (the solver maps back), but the internal solve may have operated on a much smaller problem. Setting cp_model_presolve: False disables this — useful for timing experiments, rarely for production.

Check your understanding

Why does presolve run propagators at decision level 0, before any branching?

Decoding the CpSolverResponse

When the solve completes, C++ fills a CpSolverResponse proto and serialises it back to Python. solver.Solve(model) returns the status enum, but all the detail is in the response object:

timetable.py Reading the full response after a solve
solver = CpSolver()
status = solver.Solve(model)

# Status
print(solver.StatusName(status))       # "OPTIMAL", "FEASIBLE", "INFEASIBLE", etc.

# Objective bounds
print(solver.ObjectiveValue())         # best integer objective found
print(solver.BestObjectiveBound())     # dual lower bound — from the LP relaxation

# Solution values (only valid if status is OPTIMAL or FEASIBLE)
print(solver.Value(assign_A_R1_T1))   # 0 or 1

# Solver statistics
print(solver.NumBooleans())            # Boolean variables after presolve
print(solver.NumConflicts())           # conflicts resolved by CDCL
print(solver.NumBranches())            # branch decisions taken
print(solver.WallTime())               # elapsed seconds
print(solver.UserTime())               # CPU seconds (sum across workers)

The gap between ObjectiveValue() and BestObjectiveBound() is the integrality gap from the previous lesson — how far the best found integer solution is from the proven lower bound. When both are equal, the solution is proven optimal and status == OPTIMAL. When the solver times out with a gap remaining, status == FEASIBLE and you can compute how close you are:

gap_pct = (solver.ObjectiveValue() - solver.BestObjectiveBound()) / abs(solver.ObjectiveValue()) * 100
print(f"Within {gap_pct:.1f}% of optimal")
[OR-Tools CP-SAT source: ortools/sat/cp_model.proto]
CpSolverResponse def.

The Protocol Buffer message returned by SolveCpModel(). Key fields: status (OPTIMAL / FEASIBLE / INFEASIBLE / MODEL_INVALID / UNKNOWN), objective_value (best integer objective), best_objective_bound (dual LP lower bound for minimisation), solution (one integer value per variable, indexed by proto position), num_booleans, num_conflicts, num_branches, wall_time, user_time.

solver.Value(var) decodes a variable’s solution value by looking up var’s index in response.solution:

# Simplified from cp_model.py
def Value(self, var: IntVar) -> int:
    return self.__solution[var.Index()]

If you call Value() when the status is INFEASIBLE, self.__solution is empty and you get an IndexError. Always guard on status.

Show the full status enum mapping
Status constantMeaning
OPTIMALProven globally optimal; objective matches dual bound
FEASIBLEAt least one solution found; may not be optimal
INFEASIBLEProven no solution exists under the given constraints
MODEL_INVALIDThe proto failed validation (duplicate variable names, impossible domains, etc.)
UNKNOWNTime limit hit before any solution was found

MODEL_INVALID is the one that often surprises beginners — it surfaces errors in the proto that Python-side validation missed.

Check your understanding

The solver returns status FEASIBLE with ObjectiveValue=42 and BestObjectiveBound=38 after a 30-second run. What does this tell you?

SolverParameters: Direct Knobs into the Machinery

SolverParameters is another auto-generated Protobuf message, from ortools/sat/sat_parameters.proto. Setting a parameter on the Python side serialises it along with the model and is read by the C++ solver at startup.

[OR-Tools CP-SAT source: ortools/sat/sat_parameters.proto]
solver = CpSolver()
solver.parameters.max_time_in_seconds = 30.0
solver.parameters.num_workers = 8
solver.parameters.log_search_progress = True
status = solver.Solve(model)

The table below maps each parameter to the internal mechanism it controls — the same mechanisms from earlier lessons:

Parameter What it controls When to change it
max_time_in_seconds Wall-clock limit on SolveCpModel(). Returns FEASIBLE if any solution was found, UNKNOWN if none was.Always set in production — an unbounded timetabling solve can run for minutes or hours.
num_workers Portfolio parallelism: N independent solver threads each running a different CDCL strategy (branching heuristic, restart schedule, random seed). Clause sharing between workers is limited to low-LBD clauses.Set to your core count for hard problems. Default is 1 (single-threaded).
log_search_progress Emits a human-readable log line every few seconds showing: objective value, dual bound, num_conflicts, num_branches, LP time fraction, and the current best solution.Turn on during development to understand where the solver is spending time.
cp_model_presolve Enables or disables the presolve pipeline (variable elimination, symmetry detection, level-0 propagation).Disable for timing experiments only. Presolve almost always helps on structured scheduling problems.
linearization_level How aggressively to use the LP relaxation from Glop. 0 = no LP (pure CDCL+CP), 1 = default (LP at top of tree), 2 = maximum (LP at every node, more cuts).Increase to 2 on tight packing / assignment problems where the dual bound is currently loose.
symmetry_level Aggressiveness of symmetry detection and symmetry-breaking constraint addition. Higher values find more symmetries but cost more preprocessing time.Useful for timetabling with many interchangeable teachers or rooms.
random_seed Seeds the VSIDS tie-breaking, restart schedule jitter, and portfolio worker differentiation. Same seed → same search path.Set for reproducibility in tests; vary to escape local minima on hard instances.
relative_gap_limit / absolute_gap_limit Stop as soon as (objective - dual_bound) / objective ≤ relative_gap_limit, or (objective - dual_bound) ≤ absolute_gap_limit. Returns FEASIBLE.Use when near-optimal is good enough — avoids spending hours proving the last 0.1%.
Key SolverParameters and the internal mechanism each controls

Reading the search log

With log_search_progress = True you see lines like:

#  ... objective:42  bound:38  #conflicts:12847  #branches:38291  time:29.1s

This is the integrality gap in real time. If objective stops decreasing while bound keeps rising, your dual bound is the bottleneck — try raising linearization_level. If bound is flat and objective jumps sporadically, your primal heuristics are bottlenecked — try more workers or a different random_seed.

Analogy — model.Minimize(3*x + 5*y) — objective encoding is like linearization_level=2 — more LP calls

In the previous lesson, we saw that Glop fires as a propagator to derive the dual bound, and that Gomory cuts tighten the LP polytope toward the integer hull. linearization_level=2 tells the solver to run Glop more aggressively and generate more cuts — trading LP overhead at each node for stronger pruning. The same mechanism; just dialled up.

Check your understanding

You set num_workers=8 on a 4-core machine. What happens?

A Complete Worked Example: Timetabling with Tuned Parameters

Let’s trace a minimal school-timetabling model end to end, from Python lines to proto fields to response statistics.

Problem: 2 teachers (A, B), 2 rooms (R1, R2), 2 time slots (T1, T2). Each teacher must be assigned exactly one (room, slot) pair. No room may have two teachers in the same slot. Minimise total room switches (a soft objective).

timetable.py Complete timetabling model with SolverParameters
from ortools.sat.python.cp_model import CpModel, CpSolver

model = CpModel()

# 8 Boolean assignment variables: teacher × room × slot
assign = {}
for teacher in ["A", "B"]:
    for room in ["R1", "R2"]:
        for slot in ["T1", "T2"]:
            assign[teacher, room, slot] = model.NewBoolVar(
                f"assign_{teacher}_{room}_{slot}"
            )

# Each teacher in exactly one (room, slot)
for teacher in ["A", "B"]:
    model.AddExactlyOne(
        assign[teacher, room, slot]
        for room in ["R1", "R2"]
        for slot in ["T1", "T2"]
    )

# No room conflict: at most one teacher per (room, slot)
for room in ["R1", "R2"]:
    for slot in ["T1", "T2"]:
        model.AddAtMostOne(
            assign[teacher, room, slot] for teacher in ["A", "B"]
        )

# Soft objective: prefer teachers in the same room (minimise room switches)
room_switches = model.NewIntVar(0, 2, "room_switches")
# (simplified — a real model would build this from the assignment variables)
model.Minimize(room_switches)

solver = CpSolver()
solver.parameters.max_time_in_seconds = 10.0
solver.parameters.num_workers = 4
solver.parameters.log_search_progress = True
solver.parameters.linearization_level = 1   # default LP usage
solver.parameters.random_seed = 42          # reproducible

status = solver.Solve(model)
print(f"Status: {solver.StatusName(status)}")
print(f"Objective: {solver.ObjectiveValue()}")
print(f"Dual bound: {solver.BestObjectiveBound()}")
print(f"Conflicts: {solver.NumConflicts()}")
print(f"Branches:  {solver.NumBranches()}")
print(f"Wall time: {solver.WallTime():.3f}s")

for teacher in ["A", "B"]:
    for room in ["R1", "R2"]:
        for slot in ["T1", "T2"]:
            if solver.Value(assign[teacher, room, slot]):
                print(f"  Teacher {teacher}{room} {slot}")

Tracing the proto fields this creates:

Python callProto field populated
NewBoolVar(...)model.variables — 8 entries, each domain: [0, 1]
AddExactlyOne(...)model.constraints — 2 ExactlyOneConstraintProto entries
AddAtMostOne(...)model.constraints — 4 AtMostOneConstraintProto entries
Minimize(room_switches)model.objectivevars: [index], coeffs: [1]

Reading the statistics: After solving, NumConflicts() tells you how many first-UIP cuts the CDCL engine resolved. For this tiny 8-variable problem, it will be near zero — the problem is trivially small. On a 200-teacher, 50-room, 40-slot timetable, NumConflicts() in the tens of thousands is normal, and WallTime() may exceed your time limit — which is exactly when relative_gap_limit becomes valuable.

[CP-SAT: Combining Constraint Programming and SAT with LP]

Check your understanding

After the solve, NumBooleans() returns 6, but the model has 8 Boolean variables. What likely happened?


Check your understanding: The Python API LayerQ 1 / 5

What does model.Proto().SerializeToString() return, and why is it the only thing that crosses the Python–C++ boundary?