Modeling Fairness in Employee Scheduling with CP-SAT
Equity constraints, min-max balancing, and configurable weights
Why fairness matters
Fairness in scheduling is about perception and mathematics. When employees feel the schedule is unfair, it erodes trust, increases turnover, and generates grievances that consume management time. The problem is that manual planning creates systematic biases, not by intention, but because human planners tend to assign familiar patterns. The employee who is "good at mornings" always gets mornings. The one who never complains gets the worst shifts.
These biases compound over months. By the end of a quarter, some employees have worked 10% more days than their peers in the same group. Others have been assigned exclusively to one function despite being qualified for three. Nobody planned it that way. It simply happened because no human can track the running totals of 70 employees across 30 days while simultaneously ensuring coverage, rest periods, and weekend rotation.
A CP-SAT solver can. Equity becomes a mathematical objective: minimize the gap between the employee who works the most and the one who works the least. The solver tracks every assignment, every function, every group, simultaneously. Fairness is not an afterthought. It is built into the objective function.
Workload equity: balancing days worked
The first dimension of fairness is simple: within each functional group, every employee should work approximately the same number of days per month. If a group has 7 employees and the operation needs 4 per day for 23 working days, that is 92 person-days. Divided by 7, each employee should work about 13 days.
In practice, the solver cannot always achieve a perfect split. But it can minimize the gap between the maximum and the minimum. The penalty for workload inequity is defined per group:
# For each group g:
gap = max_days_worked[g] - min_days_worked[g]
penalty += gap * WEIGHT_WORKLOAD_EQUITY
The weight of 50 per unit of gap means that every additional day of imbalance costs 50 points. A gap of 2 costs 100. A gap of 0 costs nothing. The solver naturally pushes toward the smallest achievable gap.
This is a soft constraint, not a hard one. If the solver must choose between leaving a shift uncovered (penalty 10,000) or creating a 1-day workload gap (penalty 50), it will always choose the gap. Coverage wins. But when coverage is achievable, the solver distributes the work as evenly as possible.
Qualification equity: balancing across functions
Workload equity alone is not enough. Consider an employee qualified for both security (AVSEC) and logistics (CARGO). If the solver assigns them exclusively to security shifts for the entire month, they work the same number of days as their peers, but they never touch logistics. Meanwhile, another employee gets all the logistics shifts.
This creates two problems. First, it feels unfair: certain functions are perceived as more or less desirable. Second, it creates skill atrophy: employees who never practice a qualification lose proficiency. Qualification equity addresses both.
# For each group g, for each function f:
gap = max_shifts_in_f[g] - min_shifts_in_f[g]
penalty += gap * WEIGHT_QUALIF_EQUITY
The weight of 500 per unit is 10x higher than workload equity. This reflects a deliberate design choice: within the same total workload, the solver should strongly prefer a balanced distribution across functions. An employee who works 14 days split 7/7 between two functions is a better outcome than one who works 14 days all in one function, even if the total days are identical.
The min-max approach in CP-SAT
Both equity objectives use the same modelling pattern: minimize the difference between the maximum and minimum values within a group. In CP-SAT, this is expressed using integer variables and inequality constraints.
# Create IntVar for max and min within a group
max_days = model.NewIntVar(0, 30, 'max_days_g')
min_days = model.NewIntVar(0, 30, 'min_days_g')
# Constrain max >= each employee's count, min <= each employee's count
for e in group:
model.Add(max_days >= worked_days[e])
model.Add(min_days <= worked_days[e])
# Define the gap
gap = model.NewIntVar(0, 30, 'gap_g')
model.Add(gap == max_days - min_days)
# Add to objective
model.Minimize(gap * PENALTY_WEIGHT)
The solver assigns values to max_days and min_days that satisfy all the inequality constraints. Because we minimize the gap, the solver is incentivised to push max_days down and min_days up, which means distributing work as evenly as possible across the group.
This pattern is repeated for every group (workload equity) and for every group-function pair (qualification equity). For an operation with 8 groups and 6 functions, that creates 8 workload gaps and up to 48 qualification gaps, each contributing independently to the total penalty.
Why min-max and not standard deviation?
Standard deviation is a natural measure of spread, but it is quadratic and does not decompose cleanly in a linear CP model. The min-max gap is linear, easy to express as CP-SAT constraints, and directly interpretable: a gap of 2 means the busiest employee worked exactly 2 more days than the least busy. No statistical abstraction needed.
Excluding absences from equity
An employee on vacation for 10 days out of a 30-day month can only work 20 days at most. If the rest of their group works 22-23 days, the gap is at least 2-3 days, and the solver cannot close it. Including this employee in the equity calculation penalises the solver for something it cannot control.
The solution is straightforward: employees with HOLIDAY or VACATION constraints are excluded from the equity calculation for the affected group. The solver only measures the gap among employees who are fully available (or at least equally constrained).
# Build the equity group excluding absent employees
equity_group = [e for e in group if not has_long_absence(e)]
# Only compute min-max over equity_group
for e in equity_group:
model.Add(max_days >= worked_days[e])
model.Add(min_days <= worked_days[e])
Similarly, employees with contract type "etudiant" (student) are excluded from weekend equity calculations. Students have different availability patterns and different contractual obligations. Including them in the weekend guarantee would either penalise the solver unfairly or force the solver to give students weekends they do not need, at the expense of full-time employees who do.
Perfect fairness is impossible
Consider a concrete example. A group of 7 employees, 23 working days in the month, daily need of 4 employees. That is 92 person-days across 7 people. 92 / 7 = 13.14 days each. Since you cannot work a fraction of a day, the best possible distribution is:
- 4 employees work 13 days
- 3 employees work 14 days
Total: (4 × 13) + (3 × 14) = 52 + 42 = 94. Wait, that is 94, not 92. The actual split depends on the exact coverage needs and other constraints (rest periods, weekends, qualifications). The point is that a gap of 0 is mathematically impossible when the total does not divide evenly. A gap of 1 is the theoretical minimum.
The solver finds this automatically. It does not need to be told that a gap of 1 is acceptable. It simply minimizes the gap, hits the mathematical floor, and moves on to improve other objectives. This is one of the strengths of a penalty-based approach: the solver discovers the achievable optimum without being given an explicit target.
When the gap is larger than expected
If the solver produces a gap of 3 or more within a group, it means other constraints are interfering. Common causes:
- Too few employees qualified for a specific function, forcing the same people to cover it repeatedly
- Rest period constraints (11-hour rule) creating chain effects that limit scheduling flexibility
- Fixed absences (FIXED_OFF) that are unevenly distributed across the group
The penalty breakdown makes these causes visible. A high equity penalty is a diagnostic signal, not a solver failure.
Configurable weights
The default weights (50 for workload equity, 500 for qualification equity) reflect a balanced priority where fairness matters but coverage always wins. These weights are configurable in the solver settings.
| Objective | Default weight | Effect of increasing |
|---|---|---|
| Workload equity (days) | Very low | Stricter balance of total days worked per group |
| Qualification equity (shifts/function) | Low | Stricter balance of shift types across functions |
| Missing coverage | Very high | Even stronger preference for full coverage |
| Missing days off | Medium | Stronger guarantee of contractual rest days |
Increasing the workload equity weight from 50 to 500 makes the solver treat a 1-day gap as seriously as a missing day off. This produces more balanced schedules but can degrade coverage in tight situations. The solver may leave a shift uncovered rather than create a workload imbalance.
The key insight is that weight tuning is a trade-off, not a free improvement. Higher equity weights do not create more employees. They redistribute the same workforce more evenly, potentially at the cost of other objectives. A planner who sets equity to 10,000 (equal to coverage) is telling the solver: "I would rather have an uncovered shift than an unfair schedule." That may be the right choice for some operations, but it should be a deliberate one.
Measuring fairness
After the solver runs, the penalty breakdown shows the equity gap per group. This is the primary fairness metric. Here is what different gap values mean in practice:
| Gap (days) | Assessment | Typical cause |
|---|---|---|
| 0 | Perfect equity | Group size divides evenly into total person-days |
| 1 | Excellent | Normal rounding (most common outcome) |
| 2 | Good | Minor constraint interference (rest periods, weekends) |
| 3 | Acceptable | Some qualification bottleneck or uneven absences |
| 4+ | Investigate | Structural imbalance: too few qualified employees, or conflicting hard constraints |
For qualification equity, the same logic applies per function. A gap of 0-1 shifts per function is excellent. A gap of 3+ in a specific function suggests that too few employees in the group hold that qualification, forcing the solver to over-assign the ones who do.
Equity across months
The solver optimises one month at a time. It does not carry forward equity data from previous months. If an employee worked more in January, the February solver does not know. Cross-month equity requires either manual adjustment of the input data (e.g. adjusting targets based on last month) or a meta-layer that tracks cumulative fairness. This is an operational decision, not a solver limitation.
Reporting equity to planners
The solver report includes per-group equity statistics: the min, max, and gap for both workload and qualification metrics. A planner can scan the report and immediately identify which groups are well-balanced and which need attention. If a group consistently shows a gap of 4+, the solution is usually structural: add an employee with the missing qualification, or split the group into smaller, more homogeneous units.
Want to see equity metrics on your data?
Send us your employee list and qualification matrix. We will run the solver and show you the fairness breakdown per group.
Contact us