Capstone: Instrument and Extend a CP-SAT Timetabling Solver

Apply every layer of the course — CSP formulation, CDCL conflict learning, LCG explanations, LP dual bounds, and the Protobuf API — by instrumenting a real timetabling solver with a solution callback, tuning its parameters, and extending the model with a soft room-preference constraint.

Estimated time
~45 min
Difficulty
advanced
Sources
4 sources

Eight lessons ago you saw a timetabling problem for the first time as a constraint satisfaction problem — variables, domains, constraints. You have since descended through every layer: the DPLL search tree that underlies SAT, the first-UIP clause learning that gives CDCL non-chronological backjumping, the two-watched-literal index that makes unit propagation fast, the propagator registry that fires CP constraints alongside SAT, the lazy explanation callbacks that bridge CP and CDCL, the LP dual bound that prunes entire subtrees before entering them, and the Protobuf wire format through which your Python model reaches the C++ engine. Now you put it all together — not by reading, but by building.

The arc you walked

Before you touch code, ground each capstone task in the concepts behind it:


The Brief

What you will build: a single Python file, instrumented_timetable.py, that satisfies all five acceptance criteria below.

Acceptance criteria

  1. Callback: A CpSolverSolutionCallback subclass that prints, on each new incumbent: solution number, objective value, dual lower bound, gap, conflicts, and wall time.
  2. Callback wired up: solver.Solve(model, callback) is used, not solver.Solve(model).
  3. Three tuned parameters: linearization_level, num_workers, and random_seed are set with a one-line comment explaining each choice.
  4. Soft constraint: The model includes a room-preference penalty — each teacher has a preferred room; assignments outside that preference are penalised proportionally in the objective.
  5. Hints: At least one model.AddHint(var, value) call primes the solver with a feasible-looking starting assignment.

The baseline timetabling problem you will start from:

  • Teachers: Alice, Bob, Carol
  • Rooms: R1, R2
  • Slots: Monday through Friday (5 periods)
  • Hard constraints:
    • Each teacher teaches exactly 3 periods per week.
    • At most one teacher per room per period.
  • Soft constraint (yours to add): Alice prefers R1, Bob prefers R2, Carol prefers R1. Each class taught in the non-preferred room costs 1 penalty point. Minimise total penalty.

Starter Scaffold

Copy this baseline and run it before making any changes. It should print FEASIBLE or OPTIMAL in under a second — if not, check your OR-Tools installation.

baseline_timetable.py baseline_timetable.py — the starting point
from ortools.sat.python import cp_model

TEACHERS = ["Alice", "Bob", "Carol"]
ROOMS    = ["R1", "R2"]
SLOTS    = ["Mon", "Tue", "Wed", "Thu", "Fri"]

def build_model():
    model = cp_model.CpModel()

    # Decision variables: assign[teacher, room, slot] = 1 if teacher is in room on slot
    assign = {}
    for t in TEACHERS:
        for r in ROOMS:
            for s in SLOTS:
                assign[t, r, s] = model.NewBoolVar(f"assign_{t}_{r}_{s}")

    # Hard constraint 1: each teacher teaches exactly 3 periods
    for t in TEACHERS:
        model.Add(
            sum(assign[t, r, s] for r in ROOMS for s in SLOTS) == 3
        )

    # Hard constraint 2: at most one teacher per room per slot
    for r in ROOMS:
        for s in SLOTS:
            model.AddAtMostOne(assign[t, r, s] for t in TEACHERS)

    # Hard constraint 3: a teacher can be in at most one room per slot
    for t in TEACHERS:
        for s in SLOTS:
            model.AddAtMostOne(assign[t, r, s] for r in ROOMS)

    return model, assign


def main():
    model, assign = build_model()

    solver = cp_model.CpSolver()
    status = solver.Solve(model)

    print(f"Status: {solver.StatusName(status)}")
    if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
        for t in TEACHERS:
            classes = [
                f"{s}/{r}"
                for r in ROOMS for s in SLOTS
                if solver.Value(assign[t, r, s]) == 1
            ]
            print(f"  {t}: {', '.join(classes)}")

if __name__ == "__main__":
    main()
Show the proto shape this model creates

After build_model(), model.Proto() contains:

  • 30 Boolean variables — one assign_*_*_* per (teacher × room × slot) triple.
  • 3 equality constraints — one LinearConstraintProto per teacher enforcing exactly 3 classes.
  • 10 at-most-one constraints — one AtMostOneConstraintProto per (room × slot) and 15 more per (teacher × slot).
  • No objective field — the baseline is a pure feasibility problem.

When solver.Solve(model) calls SolveCpModel(), presolve will likely fix many variables immediately: with 2 rooms, 5 slots, and 3 teachers each needing exactly 3, many (room, slot) combinations are forced.


Step 1 — Add a Solution Callback

A CpSolverSolutionCallback subclass fires its on_solution_callback() method every time the solver finds a new incumbent — a feasible solution better than any found so far. Inside that method, you have access to the same statistics exposed in CpSolverResponse: objective value, dual lower bound, conflict count, and wall time.

Implementing the callback

class InspectionCallback(cp_model.CpSolverSolutionCallback):
    """Prints one line per incumbent, showing the narrowing gap."""

    def __init__(self):
        cp_model.CpSolverSolutionCallback.__init__(self)
        self._count = 0

    def on_solution_callback(self):
        self._count += 1
        obj   = self.ObjectiveValue()
        bound = self.BestObjectiveBound()
        gap   = obj - bound
        print(
            f"  #{self._count:3d}  "
            f"obj={obj:5.0f}  bound={bound:5.0f}  gap={gap:4.0f}  "
            f"conflicts={self.NumConflicts():6d}  "
            f"time={self.WallTime():.3f}s"
        )

    @property
    def solution_count(self):
        return self._count

Wire it in by passing the callback to solver.Solve():

callback = InspectionCallback()
status = solver.Solve(model, callback)
print(f"\nTotal incumbents found: {callback.solution_count}")

What you are actually observing. Each call to on_solution_callback() corresponds to a moment where the CDCL engine committed a full assignment (no variables left unset), the CP propagators confirmed constraint satisfaction, and the resulting objective beat the previous best. The BestObjectiveBound() comes from the LP relaxation (Glop) — it tells you how far below the current incumbent the true optimum could still be. Watching these two numbers converge is watching the integrality gap close in real time.

Check your understanding

If on_solution_callback() fires three times with objectives 12, 9, 7, and the final dual bound is 6, what is the proven integrality gap at termination?


Step 2 — Tune Three Solver Parameters

Parameters are set on solver.parameters before calling solver.Solve(). Each parameter maps directly to internal machinery you have studied.

instrumented_timetable.py Tuned parameter block — add this before solver.Solve()
solver = cp_model.CpSolver()

# LP propagator aggressiveness. Level 2 runs Glop at every search node
# and generates Gomory + cover cuts maximally. Worth it on tight packing
# problems where the dual bound would otherwise be loose.
solver.parameters.linearization_level = 2

# Portfolio parallelism: 4 CDCL threads with different strategies and
# random seeds, sharing only low-LBD (high-quality) learned clauses.
# Set to your core count; diminishing returns beyond ~8.
solver.parameters.num_workers = 4

# Pin the random seed so VSIDS tie-breaking and restart jitter are
# reproducible — useful when comparing parameter variants.
solver.parameters.random_seed = 17

# Optional: emit a solver log line every few seconds (helpful for debugging)
solver.parameters.log_search_progress = True

# Optional: stop as soon as we are within 10% of optimal
# solver.parameters.relative_gap_limit = 0.10
Parameter value Internal effect When to change
linearization_level=0 Glop removed from propagator registry. No dual bound, no Gomory cuts.Pure CDCL+CP throughput; faster per-node but weaker pruning.Use for very small problems where LP overhead dominates solve time.
linearization_level=1 Glop fires at the root and periodically during search.Default. Good balance of dual-bound quality vs. LP overhead.Start here; escalate to 2 if the bound stays loose.
linearization_level=2 Glop fires at every node; maximum Gomory + cover cut generation.Strongest dual bound and pruning; highest LP cost per node.Tight packing / assignment problems with a wide integrality gap.
num_workers=1 Single CDCL thread; fully deterministic with fixed random_seed.Easiest to debug; no clause sharing overhead.Testing, timing experiments, or machines with one core.
num_workers=N N CDCL threads, each differentiated by strategy and seed.Portfolio effect: one thread may find the optimum quickly regardless of others.Production solves. Set to physical core count.
What each parameter touches inside CP-SAT

Common misconception

Setting num_workers higher than your core count will always make the solve faster.

What's actually true

Beyond your core count, OS context-switching overhead grows and threads spend cycles waiting for the CPU. On a 4-core machine, num_workers=8 is unlikely to outperform num_workers=4 and may be slower. The portfolio benefit diminishes when threads are competing for the same cores.

Check your understanding

With log_search_progress=True, you see the dual bound staying flat at 0 while the objective slowly decreases. Which parameter change is most likely to help?


Step 3 — Add a Soft Room-Preference Constraint

Hard constraints express what must be true. Soft constraints express what we prefer — violations are allowed but cost a penalty in the objective. In CP-SAT, soft constraints are modelled by introducing a penalty variable for each violation and adding it to the objective with an appropriate weight.

The preference: Alice prefers R1, Bob prefers R2, Carol prefers R1. Each class taught in the non-preferred room costs 1 penalty point.

instrumented_timetable.py Adding the soft constraint — extend build_model()
PREFERENCES = {
    "Alice": "R1",
    "Bob":   "R2",
    "Carol": "R1",
}

def build_model_with_soft():
    model, assign = build_model()   # start from the hard-constraint baseline

    # For each teacher-slot assignment in the non-preferred room, create a
    # binary penalty variable. It equals 1 exactly when the teacher is
    # assigned there — because assign[t, non_pref, s] is already Boolean.
    penalty_vars = []
    for t in TEACHERS:
        preferred = PREFERENCES[t]
        for r in ROOMS:
            if r == preferred:
                continue                        # no penalty for preferred room
            for s in SLOTS:
                # assign[t, r, s] is 1 iff teacher t is in non-preferred room r on slot s.
                # We can use it directly as the penalty indicator.
                penalty_vars.append(assign[t, r, s])

    # Minimise total preference violations.
    model.Minimize(sum(penalty_vars))

    return model, assign

Why assign[t, r, s] is already the penalty indicator

Each assign[t, r, s] is a Boolean variable that equals 1 iff teacher t is in room r on slot s. For non-preferred rooms, this variable is already exactly the penalty we want: 0 if the teacher avoids that room, 1 if they use it. There is no need to create a separate penalty variable — the existing decision variable plays both roles. This is the “indicator literal” pattern you saw in the objective-encoding section of the LP Relaxation lesson.

Weighted soft constraints. If Alice’s preference is twice as important as Bob’s, multiply her penalty terms by 2:

weights = {"Alice": 2, "Bob": 1, "Carol": 1}

penalty_terms = []
for t in TEACHERS:
    preferred = PREFERENCES[t]
    for r in ROOMS:
        if r == preferred:
            continue
        for s in SLOTS:
            # Weight the term by this teacher's priority
            penalty_terms.append(weights[t] * assign[t, r, s])

model.Minimize(sum(penalty_terms))

This maps directly onto the weighted indicator-literal sum you studied in the objective-encoding section: each assign[t, r, s] becomes an indicator literal (x ≥ 1) for a Boolean variable, and weights[t] is its objective coefficient.

Check your understanding

The baseline model had no objective. After adding the soft constraint, solver.Solve() returns OPTIMAL with ObjectiveValue=2. What does 2 mean?


Step 4 — Add Solution Hints

Hints tell the solver which assignment to try first. Internally, model.AddHint(var, value) writes a level-0 decision into the Boolean trail before the first real branch. If the hinted assignment is feasible, the solver has an incumbent immediately — and the dual bound can start pruning from a tighter ceiling.

instrumented_timetable.py Adding hints — call this after build_model_with_soft()
def add_hints(model, assign):
    # Propose a complete feasible schedule as a warm start.
    # Alice: Mon+Tue+Wed in R1, Bob: Thu+Fri+Mon in R2, Carol: Tue+Wed+Thu in R1
    hint_schedule = [
        ("Alice", "R1", "Mon"),
        ("Alice", "R1", "Tue"),
        ("Alice", "R1", "Wed"),
        ("Bob",   "R2", "Thu"),
        ("Bob",   "R2", "Fri"),
        ("Bob",   "R2", "Mon"),
        ("Carol", "R1", "Tue"),
        ("Carol", "R1", "Wed"),
        ("Carol", "R1", "Thu"),
    ]

    # Hint 1s for the suggested assignments
    for t, r, s in hint_schedule:
        model.AddHint(assign[t, r, s], 1)

    # Hint 0s for all other assignments (not strictly required but helps)
    hint_set = {(t, r, s) for t, r, s in hint_schedule}
    for t in TEACHERS:
        for r in ROOMS:
            for s in SLOTS:
                if (t, r, s) not in hint_set:
                    model.AddHint(assign[t, r, s], 0)

Hints are suggestions, not constraints. The solver may override them if they lead to a dead end. If the hinted assignment violates a hard constraint, presolve will detect the inconsistency and the hint is discarded silently — the solve continues without it. You can verify hints are being used by checking whether on_solution_callback() fires almost immediately (typically within milliseconds) with a good objective value.


Putting It All Together

Here is the complete instrumented_timetable.py — the artifact you are building.

Show the complete instrumented_timetable.py
instrumented_timetable.py instrumented_timetable.py — the finished capstone artifact
from ortools.sat.python import cp_model

TEACHERS    = ["Alice", "Bob", "Carol"]
ROOMS       = ["R1", "R2"]
SLOTS       = ["Mon", "Tue", "Wed", "Thu", "Fri"]
PREFERENCES = {"Alice": "R1", "Bob": "R2", "Carol": "R1"}


# ─── Model ───────────────────────────────────────────────────────────────────

def build_model():
    model = cp_model.CpModel()

    assign = {}
    for t in TEACHERS:
        for r in ROOMS:
            for s in SLOTS:
                assign[t, r, s] = model.NewBoolVar(f"assign_{t}_{r}_{s}")

    # Hard: each teacher teaches exactly 3 periods
    for t in TEACHERS:
        model.Add(sum(assign[t, r, s] for r in ROOMS for s in SLOTS) == 3)

    # Hard: at most one teacher per room per slot
    for r in ROOMS:
        for s in SLOTS:
            model.AddAtMostOne(assign[t, r, s] for t in TEACHERS)

    # Hard: a teacher is in at most one room per slot
    for t in TEACHERS:
        for s in SLOTS:
            model.AddAtMostOne(assign[t, r, s] for r in ROOMS)

    # Soft: penalise non-preferred room assignments
    penalty_terms = []
    for t in TEACHERS:
        preferred = PREFERENCES[t]
        for r in ROOMS:
            if r != preferred:
                for s in SLOTS:
                    penalty_terms.append(assign[t, r, s])

    model.Minimize(sum(penalty_terms))

    return model, assign


def add_hints(model, assign):
    hint_schedule = [
        ("Alice", "R1", "Mon"), ("Alice", "R1", "Tue"), ("Alice", "R1", "Wed"),
        ("Bob",   "R2", "Thu"), ("Bob",   "R2", "Fri"), ("Bob",   "R2", "Mon"),
        ("Carol", "R1", "Tue"), ("Carol", "R1", "Wed"), ("Carol", "R1", "Thu"),
    ]
    hint_set = set(hint_schedule)
    for t, r, s in hint_schedule:
        model.AddHint(assign[t, r, s], 1)
    for t in TEACHERS:
        for r in ROOMS:
            for s in SLOTS:
                if (t, r, s) not in hint_set:
                    model.AddHint(assign[t, r, s], 0)


# ─── Callback ────────────────────────────────────────────────────────────────

class InspectionCallback(cp_model.CpSolverSolutionCallback):
    """Logs each new incumbent: objective, dual bound, gap, conflicts, time."""

    def __init__(self):
        cp_model.CpSolverSolutionCallback.__init__(self)
        self._count = 0

    def on_solution_callback(self):
        self._count += 1
        obj   = self.ObjectiveValue()
        bound = self.BestObjectiveBound()
        gap   = obj - bound
        print(
            f"  #{self._count:3d}  "
            f"obj={obj:4.0f}  bound={bound:4.0f}  gap={gap:3.0f}  "
            f"conflicts={self.NumConflicts():5d}  "
            f"time={self.WallTime():.3f}s"
        )

    @property
    def solution_count(self):
        return self._count


# ─── Main ────────────────────────────────────────────────────────────────────

def main():
    model, assign = build_model()
    add_hints(model, assign)

    solver = cp_model.CpSolver()

    # LP propagator aggressiveness: 2 = Glop at every node + maximal cuts.
    # Tightens the dual bound on this packing problem.
    solver.parameters.linearization_level = 2

    # Portfolio: 4 independent CDCL threads; set to your core count.
    solver.parameters.num_workers = 4

    # Pin seed for reproducibility across parameter-tuning experiments.
    solver.parameters.random_seed = 17

    solver.parameters.max_time_in_seconds = 30.0

    print("Incumbents found during search:")
    callback = InspectionCallback()
    status = solver.Solve(model, callback)

    print(f"\nStatus:     {solver.StatusName(status)}")
    print(f"Objective:  {solver.ObjectiveValue()}")
    print(f"Dual bound: {solver.BestObjectiveBound()}")
    print(f"Conflicts:  {solver.NumConflicts()}")
    print(f"Wall time:  {solver.WallTime():.3f}s")
    print(f"Incumbents: {callback.solution_count}")

    if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
        print("\nFinal schedule:")
        for t in TEACHERS:
            classes = sorted(
                f"{s}/{r}"
                for r in ROOMS for s in SLOTS
                if solver.Value(assign[t, r, s]) == 1
            )
            pref = PREFERENCES[t]
            violations = sum(
                1 for r in ROOMS for s in SLOTS
                if r != pref and solver.Value(assign[t, r, s]) == 1
            )
            print(f"  {t} (prefers {pref}): {', '.join(classes)}{violations} violation(s)")


if __name__ == "__main__":
    main()

Self-Check Rubric

Run python instrumented_timetable.py and verify each item:

Rubric — check each box before declaring done

✓ Criterion 1 — Callback fires. The output begins with at least one # 1 obj=... line before the final status block. If no incumbent is found, the callback never fires and you will see only the final status.

✓ Criterion 2 — Gap closes. If more than one incumbent is found, each subsequent obj= should be strictly smaller than the previous one. gap= should reach 0 when status=OPTIMAL.

✓ Criterion 3 — Parameters are set. Grep your file for linearization_level, num_workers, and random_seed. Each should appear exactly once with an accompanying comment explaining the choice.

✓ Criterion 4 — Soft constraint is active. The final “violations” count printed for each teacher should be 0 or a small positive integer (not all classes in non-preferred rooms). If it is always 0, the problem is trivially satisfiable; if all teachers have many violations, the soft constraint may not be wired into the objective.

✓ Criterion 5 — Hints are present. model.AddHint should appear in the file. To confirm hints are helping: comment them out, re-run, and observe whether the callback fires sooner with hints enabled.

Expected output shape (exact numbers will vary by hardware and OR-Tools version):

Incumbents found during search:
  #  1  obj=   0  bound=   0  gap=  0  conflicts=    0  time=0.001s

Status:     OPTIMAL
Objective:  0.0
Dual bound: 0.0
Conflicts:  0
Wall time:  0.003s
Incumbents: 1

With this problem size (30 Boolean variables, tight structure), the solver typically reaches OPTIMAL on the first incumbent — the hints, together with the tight constraint structure, make the problem trivial. This is expected and correct. The rubric is satisfied when you can explain why it is trivial using concepts from the course.

Check your understanding

The solver returns OPTIMAL with 0 conflicts and 1 incumbent in 3 ms. Is this a problem? What does it tell you?


Stretch Directions

Once the baseline works, deepen your understanding in one of these directions:

Stretch 1 — Conflict analysis log. Set solver.parameters.log_search_progress = True and re-run with the hints disabled and linearization_level = 0. Compare the conflict count and wall time to the tuned version. Annotate each line of the search log with the internal mechanism responsible: which lines reflect LP dual-bound updates, which reflect CDCL conflict learning, which reflect new incumbents. Write your annotations as comments in a separate file.

Stretch 2 — Weighted preference tiers. Assign weights {"Alice": 3, "Bob": 1, "Carol": 2} to the preference penalty. Observe how the objective value and final schedule change. Then experiment: at what weight ratio does the solver start assigning Alice exclusively to R1 even at the cost of scheduling conflicts for Carol? What does this tell you about the trade-off between penalty weight and constraint satisfiability?

Stretch 3 — Add a second soft constraint. Introduce a “back-to-back avoidance” preference: teachers prefer not to teach in consecutive slots if they are in different rooms (a room-switch between adjacent periods costs an extra 2 points). Model this by creating a binary switch[t, s] variable for each consecutive-slot pair and adding it to the objective. Verify that the solver now sometimes trades a room-preference violation for avoiding a room-switch, when that reduces the weighted total.

Stretch 3 hint — detecting consecutive-slot room changes

To detect whether teacher t switches rooms between slots s and s+1, you need to know which room they are in for each slot they are teaching. One approach: create an integer variable room_index[t, s] in [0, len(ROOMS)-1] constrained to equal sum(r_idx * assign[t, ROOMS[r_idx], s] for r_idx in range(len(ROOMS))), then compare consecutive values. model.AddAbsEquality followed by an indicator constraint can turn the absolute difference into a binary switch flag.


Capstone check: full-stack understandingQ 1 / 5

on_solution_callback() fires five times. What does each firing correspond to inside CP-SAT?


You can now read a CP-SAT search log, explain what each number means in terms of the CDCL engine and LP relaxation, instrument a solver to surface that information programmatically, tune the three parameters that most affect performance, and extend a hard-constraint model with soft preferences — taking a timetabling problem from a feasibility query to a tuned, observable, optimisation system.