Constraint programming with CP-SAT by example

Constraint programming recently caught my eye as a useful skill to acquire. I wound up finding CP-SAT by google, and found I didn't immediately understand the examples. To understand myself, I wrote this in a markdown file alongside the example script that I was editing to run piece-by-piece. I was taken away from the task many times so keeping notes for myself was important to quickly learn. Hopefully the rambling's of my learning process will be useful to someone else out there, enjoy!

Source

Variables

Shifts are defined as: shifts = ['O', 'M', 'A', 'N'] This means there are three shifts available per day: Morning, Afternoon and Night. O is 'off' as in unassigned. fixed_assignments allows manual override of specific shifts as a constraint. Useful for shift swap features, or keeping important scheduling promises that the system would otherwise be unaware of. There are defined as a tuple:

    (employee, shift, day)
    fixed_assignments = [
        # Employee 0, assigned as Off, on Monday
        (0, 0, 0),
        # Employee 1 set as off on monday
        (1, 0, 0),
        # Employee 2 working Monday Morning.
        (2, 1, 0)
        ...
    ]

requests allows you to provide 'soft constraints' where we would like the model to meet all the requests, but if they're unmet the solution can still be solved. This is useful for things like timeoff requests, or other 'we'll do our best' type problems with scheduling. These are defined as a tuple:

    # Request: (employee, shift, day, weight)
    # A negative weight indicates that the employee desire this assignment.
    requests = [
        # Employee 3 does not want to work on the first Saturday (negative weight
        # for the Off shift).
        (3, 0, 5, -2),
        # Employee 5 wants a night shift on the second Thursday (negative weight).
        (5, 3, 10, -2),
        # Employee 2 does not want a night shift on the first Friday (positive
        # weight).
        (2, 3, 4, 4)
    ]

shift_constraints defines hard\soft consecutive assignment constraints such as don't assign anyone multiple night shifts in a row, or ensure everyone gets two days off in a row per week. These are defined as a tuple:

    # Shift constraints on continuous sequence :
    #     (shift, hard_min, soft_min, min_penalty,
    #             soft_max, hard_max, max_penalty)
    shift_constraints = [
        # One or two consecutive days of rest, this is a hard constraint.
        (0, 1, 1, 0, 2, 2, 0),
        # between 2 and 3 consecutive days of night shifts, 1 and 4 are
        # possible but penalized.
        (3, 1, 2, 20, 3, 4, 5),
    ]

weekly_sum_constraints defines per week summation constraints like ensure X number of breaks and between 1-4 night shifts per week. These are defined as a tuple (okay everything is just a tuple in python):

    # Weekly sum constraints on shifts days:
    #     (shift, hard_min, soft_min, min_penalty,
    #             soft_max, hard_max, max_penalty)
    weekly_sum_constraints = [
        # Constraints on rests per week.
        (0, 1, 2, 7, 2, 3, 4),
        # At least 1 night shift per week (penalized). At most 4 (hard).
        (3, 0, 1, 3, 4, 4, 0),
    ]

penalized_transitions defines which shifts people should or should not be scheduled between. I.E. there is no overnight shift, so make night->morning forbidden. Tuple:

    # Penalized transitions:
    #     (previous_shift, next_shift, penalty (0 means forbidden))
    penalized_transitions = [
        # Afternoon to night has a penalty of 4.
        (2, 3, 4),
        # Night to morning is forbidden.
        (3, 1, 0),
    ]

weekly_cover_demands declares how many staff members are required per shift per day.

    # daily demands for work shifts (morning, afternoon, night) for each day
    # of the week starting on Monday.
    weekly_cover_demands = [
        (2, 3, 1),  # Monday
        (2, 3, 1),  # Tuesday
        ...
    ]

work contains the schedule result set:

    work = {}
    for e in range(num_employees):
        for s in range(num_shifts):
            for d in range(num_days):
                work[e, s, d] = model.NewBoolVar('work%i_%i_%i' % (e, s, d))

This expands to:

{
    (0,0,0) : 'work0_0_0',
    (0,0,1) : 'work0_0_1',
    ...
    (7, 3, 20) : 'work7_3_20'
}

Where each string is actually a declared Boolean Variable in the model, in the case of this example it is 672 variables. [21 days * 4 shift types * 8 employees] What does a Boolean Value do? If the model determines this value to be true, then employee e is assigned to the shift s on day d. If false, the employee is not assigned. This is how the result set is defined and represented by the model.

Constraint Implementation

We have a data representation for each possibility of shift assignments over the period for each employee, if we let the model run right now, what would it do?

Solution 0, time = 0.03 s, objective = 0
          M T W T F S S M T W T F S S M T W T F S S
worker 0:
worker 1:
worker 2:
worker 3:
worker 4:
worker 5:
worker 6:
worker 7:
Penalties:
Statistics
  - status          : OPTIMAL
  - conflicts       : 0
  - branches        : 0
  - wall time       : 0.032026 s

It assigns... nothing! We gave the model nothing but some variables, so the best solution is to change nothing!

One shift per day

    # Exactly one shift per day.
    for e in range(num_employees):
        for d in range(num_days):
            model.AddExactlyOne(work[e, s, d] for s in range(num_shifts))

For each employee, for each day, for each shift execute AddExactlyOne. This will create (8 * 21 * 4) = 672 constraints. So it's really just creating the constraint for each variable in the model. AddExactlyOne is the same as model.Add with the expression set to equal 1. .Add adds a BoundedLinearExpression to the model. More generically, this would force the variable to set between an upper and lower bound. In our case, we set both upper and lower limit to 1, therefore enforcing that for each variable in the model, there is exactly 1 result. The output now becomes:

          M T W T F S S M T W T F S S M T W T F S S
worker 0: O O O O O O O O O O O O O O O O O O O O O
worker 1: O O O O O O O O O O O O O O O O O O O O O
worker 2: O O O O O O O O O O O O O O O O O O O O O
worker 3: O O O O O O O O O O O O O O O O O O O O O
worker 4: O O O O O O O O O O O O O O O O O O O O O
worker 5: O O O O O O O O O O O O O O O O O O O O O
worker 6: O O O O O O O O O O O O O O O O O O O O O
worker 7: O O O O O O O O O O O O O O O O O O O O O
Penalties:

The model now actually fills in the result set with exactly one result per variable. This is the same as writing model.Add(sum(work[e, s, d] for s in range(num_shifts)) == 1 ) and so we can quickly test what having two results for each variable would look like:

model.Add(sum(work[e, s, d] for s in range(num_shifts)) == 1 )
          M T W T F S S M T W T F S S M T W T F S S
worker 0: O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M
worker 1: O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M
worker 2: O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M
worker 3: O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M
worker 4: O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M
worker 5: O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M
worker 6: O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M
worker 7: O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M O M
Penalties:

As far as I can tell, this snippet is basically an initialization. It means if no further solution is found for a cell, the employee is set as not working?

Fixed Assignments & Employee Requests

Our schedule at the moment is not very useful... everyone is off all the time! To start building up constraints to actually extract value, we'll start with the low hanging fruit. This snippet adds fixed assignments that someone has manually created for one reason or another.

# Each fixed_assignment has (employee, shift, day) and if present means we want the 
# corresponding variable to be set to True to mean the employee is working
for e, s, d in fixed_assignments:
    # Set variable for this employee/shift/day to True
    model.Add(work[e, s, d] == 1)

In the sample, the fixed assignments contain a full schedule for the first two days, so the result becomes:

          M T W T F S S M T W T F S S M T W T F S S
worker 0: O M O O O O O O O O O O O O O O O O O O O
worker 1: O M O O O O O O O O O O O O O O O O O O O
worker 2: M A O O O O O O O O O O O O O O O O O O O
worker 3: M A O O O O O O O O O O O O O O O O O O O
worker 4: A A O O O O O O O O O O O O O O O O O O O
worker 5: A O O O O O O O O O O O O O O O O O O O O
worker 6: O O O A O O O O O O O O O O O O O O O O O
worker 7: N N O O O O O O O O O O O O O O O O O O O
Penalties:

Now we should also add employee requests. We want to honor these if possible, based on a weight formula.

# Employee requests
# Request: (employee, shift, day, weight)
for e, s, d, w in requests:
        obj_bool_vars.append(work[e, s, d])
        obj_bool_coeffs.append(w)

For each request, we append the shift tuple to an array, and in another array append it's weight. These are eventually fed into the model through this Snippet:

model.Minimize(
    sum(obj_bool_vars[i] * obj_bool_coeffs[i]
        for i in range(len(obj_bool_vars))) +
    sum(obj_int_vars[i] * obj_int_coeffs[i]
        for i in range(len(obj_int_vars))))

So for each variable and weight we append to those lists, it'll end up in a summation that the model is trying to find the lowest possible value for. Using an example, let's look at one of the requests set:

# Employee 3 does not want to work on the first Saturday (negative weight
# for the Off shift).
(3, 0, 5, -2),

This would wind up in the minimize function as:

BooleanVariable("work3_0_5") * -2

Meaning, the optimal solution would be to have BooleanVariable("work3_0_5") == 1 for a value of -2 for the function. The result would be worker 3 not working on the first saturday.

          M T W T F S S M T W T F S S M T W T F S S
worker 0: O M O O O O O O O O O O O O O O O O O O O
worker 1: O M O O O O O O O O O O O O O O O O O O O
worker 2: M A O O O O O O O O O O O O O O O O O O O
worker 3: M A O O O O O O O O O O O O O O O O O O O
worker 4: A A O O O O O O O O O O O O O O O O O O O
worker 5: A O O O O O O O O O N O O O O O O O O O O
worker 6: O O O A O O O O O O O O O O O O O O O O O
worker 7: N N O O O O O O O O O O O O O O O O O O O
Penalties:
  work3_0_5 fulfilled, gain=2
  work5_3_10 fulfilled, gain=2

Looking through the requests manually to confirm here, they've all been met, sweet!

Shift Sequence Constraints

Now let's apply the constraints set in shift_constraints. Snippet:

    # Iterate over defined shift constraints
    for ct in shift_constraints:
        # unpack tuple into local variables
        shift, hard_min, soft_min, min_cost, soft_max, hard_max, max_cost = ct
        # For each employee
        for e in range(num_employees):
            # Pull each shift the employee is working (only one shift per day maximum)
            # right now...
            works = [work[e, shift, d] for d in range(num_days)]
            # Execute add_soft_sequence_constraint...
            variables, coeffs = add_soft_sequence_constraint(
                model, works, hard_min, soft_min, min_cost, soft_max, hard_max,
                max_cost,
                'shift_constraint(employee %i, shift %i)' % (e, shift))
            # Store result in vars<->coeffs map to be passed to the model in a minimization function
            obj_bool_vars.extend(variables)
            obj_bool_coeffs.extend(coeffs)

Comment provided in add_soft_sequence_constraint

    """Sequence constraint on true variables with soft and hard bounds.
  This constraint look at every maximal contiguous sequence of variables
  assigned to true. It forbids sequence of length < hard_min or > hard_max.
  Then it creates penalty terms if the length is < soft_min or > soft_max.
  Args:
    model: the sequence constraint is built on this model.
    works: a list of Boolean variables.
    hard_min: any sequence of true variables must have a length of at least
      hard_min.
    soft_min: any sequence should have a length of at least soft_min, or a
      linear penalty on the delta will be added to the objective.
    min_cost: the coefficient of the linear penalty if the length is less than
      soft_min.
    soft_max: any sequence should have a length of at most soft_max, or a linear
      penalty on the delta will be added to the objective.
    hard_max: any sequence of true variables must have a length of at most
      hard_max.
    max_cost: the coefficient of the linear penalty if the length is more than
      soft_max.
    prefix: a base name for penalty literals.
  Returns:
    a tuple (variables_list, coefficient_list) containing the different
    penalties created by the sequence constraint.
  """

To understand this with an example, let's look at one of the shift constraints passed to this function:

    # Shift constraints on continuous sequence :
    #     (shift, hard_min, soft_min, min_penalty,
    #             soft_max, hard_max, max_penalty)
    shift_constraints = [
        # One or two consecutive days of rest, this is a hard constraint.
        (0, 1, 1, 0, 2, 2, 0),
        # between 2 and 3 consecutive days of night shifts, 1 and 4 are
        # possible but penalized.
        (3, 1, 2, 20, 3, 4, 5),
    ]

The first constraint enforces that each employee is assigned at least one day off, and at most two days off in a row. This is a hard constraint meaning it has to be met for any valid solution. We know it is a hard constraint because hard_min == soft_min and hard_max == soft_max, meaning there is no wiggle room between hard/soft. We're targeting shift 0 which means the 'Off' shift. I think the penalties are set to 0 because they don't matter - it's going to set a hard constraint that has to be enforced. Let's execute the code with just the first constraint and see what happens! The last time we executed this, the schedule had filled in everyone as mostly being off everyday (wouldn't that be awesome?). This constraint should make that solution invalid...

          M T W T F S S M T W T F S S M T W T F S S
worker 0: O M M M M M M M M M M M M M M M M M M M M
worker 1: O M M M M M M M M M M M M M M M M M M M M
worker 2: M A M M M M M M M M M M M M M M M M M M M
worker 3: M A M M M O M M M M M M M M M M M M M M M
worker 4: A A M M M M M M M M M M M M M M M M M M M
worker 5: A O M M M M M M M M N M M M M M M M M M M
worker 6: M O M A M M M M M M M M M M M M M M M M M
worker 7: N N M M M M M M M M M M M M M M M M M M M

This did not work as I expected! It means no more than 1-2 consecutive days off in a row. It allows for 0 consecutive days off. The next example is:

# between 2 and 3 consecutive days of night shifts, 1 and 4 are
# possible but penalized.
(3, 1, 2, 20, 3, 4, 5),

If I understand correctly, this will only give worker 5 2nd Friday an additional night shift. This is only because they've already requested a night shift on the 2nd Thursday, and this constraint penalizes isolated 1 night shift, so it adds a second!

          M T W T F S S M T W T F S S M T W T F S S
worker 0: O M M M M M M M M M M M M M M M M M M M M
worker 1: O M M M M M M M M M M M M M M M M M M M M
worker 2: M A M M M M M M M M M M M M M M M M M M M
worker 3: M A M M M O M M M M M M M M M M M M M M M
worker 4: A A M M M M M M M M M M M M M M M M M M M
worker 5: A O M M M M M M M N N M M M M M M M M M M
worker 6: M O M A M M M M M M M M M M M M M M M M M
worker 7: N N M M M M M M M M M M M M M M M M M M M

Indeed the only change is providing that worker another shift on that day.

Weekly Sum Constraints

These are descriptive, they constrain limits on number of shifts within a week span.

    # Weekly sum constraints
    for ct in weekly_sum_constraints:
        shift, hard_min, soft_min, min_cost, soft_max, hard_max, max_cost = ct
        for e in range(num_employees):
            for w in range(num_weeks):
                works = [work[e, shift, d + w * 7] for d in range(7)]
                variables, coeffs = add_soft_sum_constraint(
                    model, works, hard_min, soft_min, min_cost, soft_max,
                    hard_max, max_cost,
                    'weekly_sum_constraint(employee %i, shift %i, week %i)' %
                    (e, shift, w))
                obj_int_vars.extend(variables)
                obj_int_coeffs.extend(coeffs)
    # Weekly sum constraints on shifts days:
    #     (shift, hard_min, soft_min, min_penalty,
    #             soft_max, hard_max, max_penalty)
    weekly_sum_constraints = [
        # Constraints on rests per week.
        (0, 1, 2, 7, 2, 3, 4),
        # At least 1 night shift per week (penalized). At most 4 (hard).
        (3, 0, 1, 3, 4, 4, 0),
    ]

The first example is saying for 'Off' shifts, set a constraint of 1->3 total rests per week, with a high penalty for working less than 1 (7) and a less high penalty for having more than 4. This translates to "Please take at least one day off a week, and overtime will be allowed on case-by-case basis". Applying this to our current set should assign some days off to our overworked staff!

          M T W T F S S M T W T F S S M T W T F S S
worker 0: O M O M M M M O O M M M M M M M M M M O O
worker 1: O M O M M M M O O M M M M M M M M M M O O
worker 2: M A O M M O M O O M M M M M M M M M M O O
worker 3: M A O M M O M O O M M M M M M M M M M O O
worker 4: A A O M M O M O O M M M M M M M M M M O O
worker 5: A O M M O M M O M N N M M O M M M M M O O
worker 6: M O M A O M M O O M M M M M M M M M M O O
worker 7: N N O M M O M O O M M M M M M M M M M O O
Penalties:
  work3_0_5 fulfilled, gain=2
  work5_3_10 fulfilled, gain=2

That's what it did! Oh shit though, we have a few days with no staff! That's probably not ideal. The next example ensures everyone is taking some night shifts... nobody wants to do it, but someone has to!

# At least 1 night shift per week (penalized). At most 4 (hard).
(3, 0, 1, 3, 4, 4, 0),

This enforces that for the night shift, employees should be assigned at least 1 night shift (penalty of 3 for 0) and a hard constraint of no more than 4 per week. Right now our schedule has employee 0, 1, 2, 3, 4, and 6 with no night shifts ever. And in weeks 2 and 3 worker 7 has no night shifts, and in weeks 1 and 2 worker 5 has no night shifts. That's all about to change!

          M T W T F S S M T W T F S S M T W T F S S
worker 0: O M M M N N O O M M O M M N N M O M O M M
worker 1: O M N N O M M O M M M O M N N M O O M M M
worker 2: M A N N O O M O M M M O M N N O O M M M M
worker 3: M A N N O O M O O M M M M N N O O M M M M
worker 4: A A N N O O M O O M M M M N N O O M M M M
worker 5: A O N N O M M O O N N M M N N O O M M M M
worker 6: M O O A N N M O O M M M M N N O O M M M M
worker 7: N N O O M M M O O M M M M N N O O M M M M

Alright that's cute but now we have days where everyone is working at night, we're empty during the day!

Transition Constraints

By the term transition, I assume this enforces certain state transitions. Like make sure no worker is assigned a night shift with a following morning shift the next day, because... worker's rights. To understand this one, let's look at how we're storing the constraint data first:

    # Penalized transitions:
    #     (previous_shift, next_shift, penalty (0 means forbidden))
    penalized_transitions = [
        # Afternoon to night has a penalty of 4.
        (2, 3, 4),
        # Night to morning is forbidden.
        (3, 1, 0),
    ]

Now for each constraint, we'll need to iterate over all the shifts and add boolean variables to the model containing expressions for each constraint. If it is a hard constraint, we declare an Or operation on the inverse of each shift specified. I.E. if we should not assign a morning following a night shift, we either don't work the night or don't work the morning! Easy enough... For a soft constraint, we use the same logic but add it to our minimization function with a set weight instead.

    # Penalized transitions
    for previous_shift, next_shift, cost in penalized_transitions:
        for e in range(num_employees):
            for d in range(num_days - 1):
                transition = [
                    work[e, previous_shift, d].Not(), work[e, next_shift,
                                                           d + 1].Not()
                ]
                if cost == 0:
                    model.AddBoolOr(transition)
                else:
                    trans_var = model.NewBoolVar(
                        'transition (employee=%i, day=%i)' % (e, d))
                    transition.append(trans_var)
                    model.AddBoolOr(transition)
                    obj_bool_vars.append(trans_var)
                    obj_bool_coeffs.append(cost)

So, the first example is:

# Afternoon to night has a penalty of 4.
(2, 3, 4),

This is saying that we really don't want employees to work an afternoon and then an evening. We'd prefer if they would come in all well rested to grind all night. In our most recent schedule, there are four of these instances for different employees. Adding this constraint will likely remove them all.

          M T W T F S S M T W T F S S M T W T F S S
worker 0: O M M M N N O M O O N N M M M O N N O M M
worker 1: O M N N O M M O M M M O M N N M O O M M M
worker 2: M A O M M O N N O M M O M N N O O M M M M
worker 3: M A O N N O M O M M M O M N N O O M M M M
worker 4: A A O N N O M O O M M M M N N O O M M M M
worker 5: A O N N O M M O O N N M M N N O O M M M M
worker 6: O O M A M N N O O M M M M N N O O M M M M
worker 7: N N O O M M M O O M M M M N N O O M M M M

good bot. The next example:

# Night to morning is forbidden.
(3, 1, 0),

Simple! We absolutely refuse to dangerously overwork our employees, good policy I'd say. There are current 3 instances of this, either they'll be removed or the algorithm will fail.

          M T W T F S S M T W T F S S M T W T F S S
worker 0: O M N N O M M O O M M M M N N O O M M M A
worker 1: O M N N O M M M M M M O O N N O M M M O A
worker 2: M A M O O M N N O M M M O N N O M M M O A
worker 3: M A M N N O O M M M M O O N N O M M M O A
worker 4: A A M N N O O M M M M O O N N O M M M O A
worker 5: A O N N O M M M M N N O O N N O M M M O A
worker 6: O O M A M N N O M M M M O N N O M M M O A
worker 7: N N O M M M O M M M M O O N N O M M M O A

Good bot.

Cover Constraints

Does this mean one employee covering another's shift, or ensuring that each day we have X employees on each shift? Here are the example constraints:

    # daily demands for work shifts (morning, afternoon, night) for each day
    # of the week starting on Monday.
    weekly_cover_demands = [
        (2, 3, 1),  # Monday
        (2, 3, 1),  # Tuesday
        (2, 2, 2),  # Wednesday
        (2, 3, 1),  # Thursday
        (2, 2, 2),  # Friday
        (1, 2, 3),  # Saturday
        (1, 3, 1),  # Sunday
    ]
    # Penalty for exceeding the cover constraint per shift type.
    excess_cover_penalties = (2, 2, 5)

It is the latter, this defines how many employees we need on each shift. Perfect this is obviously very useful... So we're saying all week we need 2 folks in the AM, between 2-3 in the afternoon and 1-2 at night. Weekends we cut down during the day and for some reason full send it on saturday nights. Maybe this is like a convenience store or something.

    # Cover constraints
    for s in range(1, num_shifts):
        for w in range(num_weeks):
            for d in range(7):
                works = [work[e, s, w * 7 + d] for e in range(num_employees)]
                # Ignore Off shift.
                min_demand = weekly_cover_demands[d][s - 1]
                worked = model.NewIntVar(min_demand, num_employees, '')
                model.Add(worked == sum(works))
                over_penalty = excess_cover_penalties[s - 1]
                if over_penalty > 0:
                    name = 'excess_demand(shift=%i, week=%i, day=%i)' % (s, w,
                                                                         d)
                    excess = model.NewIntVar(0, num_employees - min_demand,
                                             name)
                    model.Add(excess == worked - min_demand)
                    obj_int_vars.append(excess)
                    obj_int_coeffs.append(over_penalty)

This is easy enough to understand by this point, the other constraints were a little more complex. We're grabbing full week's worth of variables at a time and creating an expression with Int this time to enforce the constraints on each shift for the summation over the week. We then dump that expression into our minimization function! This should significantly change our schedule. There are some days with everyone only working night shifts!

Solution 0, time = 0.06 s, objective = 84
Solution 1, time = 0.08 s, objective = 80
Solution 2, time = 0.13 s, objective = 73
Solution 3, time = 0.13 s, objective = 71
Solution 4, time = 0.15 s, objective = 66
Solution 5, time = 0.38 s, objective = 64
Solution 6, time = 0.50 s, objective = 38
          M T W T F S S M T W T F S S M T W T F S S
worker 0: O M N N A A O M A A A A O O A O A O A A A
worker 1: O M A M N N O A O O M N N A A M M O N N O
worker 2: M A O A O A M A A O A M M A A A A A O O A
worker 3: M A A O A O A M N N O O A A O A O A M N N
worker 4: A A O O M N N N O A A M A O O A M M N N O
worker 5: A O M M N N A O M N N A O M M M N N A A O
worker 6: A O M A M O A A A M O N N O M N N A O O M
worker 7: N N N A O M O O M M M O N N N O O M M M A
Penalties:
  work3_0_5 fulfilled, gain=2
  work5_3_10 fulfilled, gain=2
  weekly_sum_constraint(employee 2, shift 0, week 1): under_sum violated by 1, linear penalty=7
  weekly_sum_constraint(employee 5, shift 0, week 0): under_sum violated by 1, linear penalty=7
  weekly_sum_constraint(employee 5, shift 0, week 2): under_sum violated by 1, linear penalty=7
  weekly_sum_constraint(employee 0, shift 3, week 1): under_sum violated by 1, linear penalty=3
  weekly_sum_constraint(employee 0, shift 3, week 2): under_sum violated by 1, linear penalty=3
  weekly_sum_constraint(employee 2, shift 3, week 0): under_sum violated by 1, linear penalty=3
  weekly_sum_constraint(employee 2, shift 3, week 1): under_sum violated by 1, linear penalty=3
  weekly_sum_constraint(employee 2, shift 3, week 2): under_sum violated by 1, linear penalty=3
  weekly_sum_constraint(employee 3, shift 3, week 0): under_sum violated by 1, linear penalty=3
  weekly_sum_constraint(employee 6, shift 3, week 0): under_sum violated by 1, linear penalty=3
Statistics
  - status          : OPTIMAL
  - conflicts       : 603
  - branches        : 4922
  - wall time       : 0.667585 s

Now that's a schedule! Nice! This execution significantly increased the time to solve. We went from 0.092848 s to 0.667585 s, a ~7x increase.

Conclusion

There are a ton of obvious gaps in the usefulness of this exact example, but it does a wonderful job at introducing the concepts of constraint programming and how to efficiently solve them with CP-SAT.