Workforce¶
Problem Statement¶
Assigning workers optimally to fit a work schedule.
Full example scenario
A company needs to assign workers to shifts over a two-week period, at the lowest possible total labor cost. Each worker has their own flat pay rate per shift, and not every worker is available for every shift. Which worker should cover each shift?
There are 14 shifts, Mon1 through Sun14, each requiring a certain number of workers: Mon1: 3, Tue2: 2, Wed3: 4, Thu4: 2, Fri5: 5, Sat6: 4, Sun7: 3, Mon8: 2, Tue9: 2, Wed10: 3, Thu11: 4, Fri12: 4, Sat13: 7, Sun14: 5.
There are 7 workers, each paid a flat rate per shift: Amy 10, Bob 12, Cathy 10, Dan 8, Ed 8, Fred 9, Gu 11.
Each worker is available for only some of the 14 shifts. The availability data can be found in
workforce.csv, with the columnsworker,shift— one row per (worker, shift) pair the worker is available for.
What an optimal solution looks like
Total labor cost
$458
Shifts covered
50
Workers used
7 of 7
Who's on the schedule, shift by shift.
Each row is a worker; consecutive shifts they're booked for run together as one bar, colored by worker. The two cheapest workers, Dan and Ed (both $8/shift), are booked onto almost every shift they're available for; the most expensive, Bob ($12/shift), only twice. The Total row is the headcount actually on shift each day.
This is the baseline plan, before the workload cap, weekend guarantees and overtime pay introduced in the Changing the model tab. Uncapped, Dan and Ed absorb 11 of the 14 shifts each — exactly the concentration the workload cap is later added to prevent.
Built with Modeler¶
The problem above is stated as a single shot: you hand over the whole problem and get a model back. Modeler takes the other route — you start with the business question you actually have, and it works with you until the problem is pinned down, the data is in, the code runs, and the answer holds up when the problem changes.
The tabs below walk the same workforce scheduling problem through that route, one Modeler capability at a time.
Instead of writing the whole prompt up front, you start with the business question you actually have. Modeler searches the knowledge base, asks one clarifying question at a time — always the most important one first — and tells you which parts of the model are still missing. The options it offers come from examples in the knowledge base and from how similar problems are usually modeled in your industry, so you can answer with a number instead of prose.
I run a small operations team and need to build a weekly shift schedule. I have a set of workers, each with their own hourly pay rate, and a list of shifts over a two-week period that each need a certain number of people assigned. Not every worker is available for every shift. I want to figure out who should work which shifts so that all the shift staffing requirements are met at the lowest possible total labor cost. Can you help me build a model for that?
The user is describing a shift scheduling / workforce assignment problem. Let me search the knowledge base for how this is usually modeled, then ask the first clarifying question. Cost-minimization with a per-worker rate is the twist here, so the first thing to pin down is how that rate actually applies — per hour or per shift — since that decides how the objective is built.
This is a great use case for optimization. The knowledge base has a solid workforce scheduling foundation I can build on, and your cost-minimization angle adds a nice twist. Let me ask a few targeted questions to tailor the specification to your exact needs.
Question 1: Does each shift have a fixed duration (in hours), or does the duration vary by shift?
All shifts have the same fixed length (e.g., always 8 hours)
Each shift has its own defined duration in hours (e.g., a morning shift is 6 hours, an evening shift is 10 hours)
The schedule is based on a fixed number of shifts per worker (not hours), and cost is simply a flat rate per shift worked
Option 3 — it’s a flat rate per shift worked, not per hour. Each worker has their own flat rate: Amy 10, Bob 12, Cathy 10, Dan 8, Ed 8, Fred 9, Gu 11. There are 14 shifts across two weeks (Mon1 through Sun14), each needing a certain number of workers: Mon1:3, Tue2:2, Wed3:4, Thu4:2, Fri5:5, Sat6:4, Sun7:3, Mon8:2, Tue9:2, Wed10:3, Thu11:4, Fri12:4, Sat13:7, Sun14:5. Not every worker is available for every shift — I’ll attach the availability. Also, I’d like the output to break down total cost by worker, not just give me one grand total, so I can see who’s costing me the most.
That is option 3, plus all seven worker rates, all fourteen shift requirements, and an output preference. Recording the objective and both input entities in the specification. The one thing still missing is the availability data itself — the user has already said they will attach it, so there is nothing left to ask; just wait for the file.
The specification is saved. You mentioned you would attach the availability data — that is the last key piece needed before the specification is complete.
Question 2: Could you share the availability data now?
You can provide it in any format that is convenient for you — a worker-by-shift table, a list of shifts each worker is available for, or a file. This availability matrix is the final input needed to complete the model.
availability.csv attached
Modeler updates the specification after every answer, even while it is still incomplete — so you can always see what it has understood and what it is still waiting on.
Modeler asked for the availability data as a file rather than as dozens of
individual worker/shift answers typed into the chat. Attach workforce.csv
to the conversation and it reads the real shape of your data instead of
guessing at it — one row per available (worker, shift) pair.
worker,shift
Amy,Tue2
Amy,Wed3
Amy,Fri5
Amy,Sun7
Amy,Tue9
Amy,Wed10
Amy,Thu11
Amy,Fri12
Amy,Sat13
Amy,Sun14
Bob,Mon1
Bob,Tue2
Bob,Fri5
Bob,Sat6
Bob,Mon8
Bob,Thu11
Bob,Sat13
Cathy,Wed3
Cathy,Thu4
Cathy,Fri5
Cathy,Sun7
Cathy,Mon8
Cathy,Tue9
Cathy,Wed10
Cathy,Thu11
Cathy,Fri12
Cathy,Sat13
Cathy,Sun14
Dan,Tue2
Dan,Wed3
Dan,Fri5
Dan,Sat6
Dan,Mon8
Dan,Tue9
Dan,Wed10
Dan,Thu11
Dan,Fri12
Dan,Sat13
Dan,Sun14
Ed,Mon1
Ed,Tue2
Ed,Wed3
Ed,Thu4
Ed,Fri5
Ed,Sun7
Ed,Mon8
Ed,Tue9
Ed,Thu11
Ed,Sat13
Ed,Sun14
Fred,Mon1
Fred,Tue2
Fred,Wed3
Fred,Sat6
Fred,Mon8
Fred,Tue9
Fred,Fri12
Fred,Sat13
Fred,Sun14
Gu,Mon1
Gu,Tue2
Gu,Wed3
Gu,Fri5
Gu,Sat6
Gu,Sun7
Gu,Mon8
Gu,Tue9
Gu,Wed10
Gu,Thu11
Gu,Fri12
Gu,Sat13
Gu,Sun14
availability.csv attached
Here’s the availability data — one row per (worker, shift) pair showing which shifts each worker is available for. Any worker/shift pair not listed means that worker is not available for that shift.
Reading the attachment. One row per (worker, shift) pair, long format — 72 valid pairs out of the 98 possible combinations of 7 workers and 14 shifts. No units to reconcile here since availability is a plain yes/no relationship, not a quantity. This is the last open item from the specification, so I can move straight to confirming and offering to implement.
The specification is now complete. Here is a summary of what has been captured:
Weekly Shift Scheduling Optimization — Specification Summary
Objective: Minimize total labor cost (flat rate per shift worked, summed across all workers and their assignments).
Constraints:
Inputs: 7 workers with individual flat rates (Dan and Ed at 8 the cheapest, Bob at 12 the most expensive); 14 shifts over two weeks with staffing requirements ranging from 2 (Mon8, Tue9) to 7 (Sat13); 72 valid availability pairs.
Output: Optimal assignment per shift, total cost, and a per-worker cost breakdown.
Does this look right to you? If so, I will move on to building the Gurobi implementation and unit tests.
Where your files live
Everything in the conversation — the file you attached and every artifact Modeler generates from it — lands in the conversation workspace as an attachment. Each one opens in a side panel next to the chat, so you can read the specification while you keep answering questions, or scroll the code while you ask for a change. From that panel you can copy the contents or download the file.
By the end of this conversation the workspace holds six items:
Attachment |
What it is |
|---|---|
|
The availability data you attached. |
|
The specification, rewritten after every answer. |
|
The |
|
The |
|
Output of the test runs, including the solver logs. |
|
The mathematical formulation, generated on request. |
Starting from files you already have
The same upload path runs in reverse. Attach the files of an existing
optimization project — a specification, a model script, a test file, or any
subset — and ask Modeler to check the project. It completes the
specification where it is thin (asking clarifying questions, as above), fills in
a missing implementation or test suite, reconciles code that has drifted from the
specification, and runs the tests. Point it at a model written for another solver
or another language and it recovers a specification from that file, asks you to
confirm it, then reimplements the model in gurobipy.
The specification is the artifact the questions are building toward, and the one everything else is generated from. Modeler rewrites it after every answer — even while it is still incomplete — so it doubles as the running record of what has been agreed and what is still open. It follows a fixed seven-section structure and is written entirely in business terms: no mathematics, so the person who owns the problem can read it and say whether it is right.
1. Overview
A small operations team needs a weekly shift schedule across a two-week period. The team has a fixed set of workers, each with a flat cost rate per shift worked. A list of shifts must each be staffed with a required number of workers, and not every worker is available for every shift. The model decides which worker covers each shift.
2. Objective
Minimize the total labor cost across all shift assignments, where each assignment incurs a flat cost equal to the assigned worker’s per-shift rate.
3. Constraints
C1 — Shift staffing requirement. Each shift must be assigned exactly the required number of workers — no more, no less.
C2 — Availability. A worker may only be assigned to a shift for which they are marked as available. Assignments outside a worker’s availability are not permitted.
C3 — Binary assignment. Each worker is either assigned to a shift or not. Partial or fractional assignments are not allowed.
4. Input
Entity |
Fields |
Values |
|---|---|---|
Worker |
name, rate (flat cost per shift) |
Amy 10, Bob 12, Cathy 10, Dan 8, Ed 8, Fred 9, Gu 11 |
Shift |
name, requirement (workers needed) |
14 shifts, Mon1–Sun14, requirements from 2 to 7 |
Availability |
worker, shift — one row per available pair |
72 valid pairs, supplied in |
Total required staffing across all 14 shifts is 50 worker-shifts, against 72 valid (worker, shift) pairs, so the schedule is workable but the cheaper workers are far more sought-after than the expensive ones.
5. Output
The assignment plan: which workers are on each shift.
Total labor cost.
A per-worker cost breakdown, so it is clear who is costing the most.
6. Test scenarios
ID |
Scenario |
Acceptance criteria |
|---|---|---|
T1 |
Full dataset finds an optimal feasible schedule. |
Optimal. Total cost 458. Every shift staffed exactly; every assignment respects availability. |
T2 |
Per-worker cost breakdown. |
Sum of per-worker costs equals the total cost, and each worker’s cost equals their rate times the number of shifts they are assigned. |
T3 |
A shift with no available worker. |
Reported infeasible. |
T4 |
Minimal edge case — one worker, one shift. |
Optimal; cost equals that worker’s rate. |
T5 |
Two workers available for one shift at different rates. |
The cheaper worker is chosen. |
7. Out of scope
Worker shift limits (a minimum or maximum number of shifts per worker per period).
Overtime or premium pay rates.
Shift preferences or worker satisfaction scoring.
Multi-week rolling schedules beyond the defined two-week window.
Part-time or partial shift coverage.
With the specification settled, Modeler writes the gurobipy implementation.
This is the version shown running in the Cloud solve tab — it passed every
test in the suite on its first run.
"""Shift scheduling: which workers to assign to which shifts to meet every
shift's staffing requirement at minimum total labor cost, subject to worker
availability.
Implements the specification in workforce_modeler_spec.rst.
"""
import pandas as pd
import gurobipy as gp
from gurobipy import GRB
# Business data from the specification.
RATES = {"Amy": 10, "Bob": 12, "Cathy": 10, "Dan": 8, "Ed": 8, "Fred": 9, "Gu": 11}
REQUIREMENTS = {
"Mon1": 3, "Tue2": 2, "Wed3": 4, "Thu4": 2, "Fri5": 5, "Sat6": 4, "Sun7": 3,
"Mon8": 2, "Tue9": 2, "Wed10": 3, "Thu11": 4, "Fri12": 4, "Sat13": 7, "Sun14": 5,
}
def read_availability(path):
"""Read workforce.csv into a list of (worker, shift) availability pairs."""
frame = pd.read_csv(path)
return list(zip(frame["worker"], frame["shift"]))
def solve_workforce(rates, requirements, availability, time_limit=None):
"""Solve the shift scheduling model.
Args:
rates: {worker: flat rate per shift}.
requirements: {shift: number of workers required}.
availability: list of (worker, shift) pairs the worker may be
assigned to.
time_limit: optional solver time limit in seconds.
Returns:
A dict with the assignment plan and the per-worker cost breakdown,
or ``{"status": "infeasible"}`` when no assignment satisfies every
shift's staffing requirement.
"""
workers = list(rates)
shifts = list(requirements)
with gp.Env() as env, gp.Model("workforce", env=env) as model:
if time_limit is not None:
model.Params.TimeLimit = time_limit
# assign[w, s]: 1 if worker w is assigned to shift s. Only defined
# for valid availability pairs, which enforces C2 structurally.
assign = model.addVars(availability, vtype=GRB.BINARY, name="assign")
model.setObjective(
gp.quicksum(rates[w] * assign[w, s] for (w, s) in availability),
GRB.MINIMIZE,
)
# C1: every shift is staffed with exactly its required headcount.
model.addConstrs(
(
gp.quicksum(assign[w, s] for (w, s2) in availability if s2 == s)
== requirements[s]
for s in shifts
),
name="staffing",
)
model.optimize()
if model.Status == GRB.INFEASIBLE:
return {"status": "infeasible"}
if model.SolCount == 0:
return {"status": "no_solution", "gurobi_status": model.Status}
assign_values = model.getAttr("X", assign)
assignments = {s: [] for s in shifts}
shift_counts = {w: 0 for w in workers}
for (w, s) in availability:
if assign_values[w, s] > 0.5:
assignments[s].append(w)
shift_counts[w] += 1
worker_cost = {
w: rates[w] * shift_counts[w] for w in workers if shift_counts[w] > 0
}
return {
"status": "optimal",
"assignments": assignments,
"worker_cost": worker_cost,
"total_cost": sum(worker_cost.values()),
}
def main():
availability = read_availability("workforce.csv")
result = solve_workforce(RATES, REQUIREMENTS, availability)
if result["status"] != "optimal":
print(f"No optimal schedule found: {result['status']}")
return
print(f"Total labor cost: ${result['total_cost']:.2f}\n")
print("Shift assignments:")
for shift in REQUIREMENTS:
print(f" {shift:<8} {', '.join(sorted(result['assignments'][shift]))}")
print("\nPer-worker cost breakdown:")
for worker, cost in sorted(result["worker_cost"].items()):
print(f" {worker:<8} ${cost:.2f}")
if __name__ == "__main__":
main()
Alongside the implementation, Modeler writes a pytest suite with one test per
scenario in the specification. Each test references its scenario identifier, and
uses the small hard-coded worker and shift data with a short solver time limit
so the suite runs in well under a second.
"""One test per test scenario in workforce_modeler_spec.rst.
Each test uses the small hard-coded data set from the specification and a short
solver time limit, so the whole suite runs in well under a second.
"""
import pytest
from workforce_modeler import RATES, REQUIREMENTS, read_availability, solve_workforce
AVAILABILITY = read_availability("workforce.csv")
TIME_LIMIT = 10
def run(rates=None, requirements=None, availability=None):
return solve_workforce(
rates or RATES,
requirements or REQUIREMENTS,
availability if availability is not None else AVAILABILITY,
time_limit=TIME_LIMIT,
)
def test_t1_baseline_is_optimal_and_meets_every_constraint():
"""T1: full dataset finds an optimal, feasible schedule."""
result = run()
assert result["status"] == "optimal"
assert result["total_cost"] == pytest.approx(458)
for shift, count in REQUIREMENTS.items():
assert len(result["assignments"][shift]) == count
valid_pairs = set(AVAILABILITY)
for shift, workers in result["assignments"].items():
for worker in workers:
assert (worker, shift) in valid_pairs
def test_t2_per_worker_cost_breakdown_sums_to_total():
"""T2: per-worker cost breakdown sums correctly to the total cost."""
result = run()
shift_counts = {w: 0 for w in RATES}
for workers in result["assignments"].values():
for w in workers:
shift_counts[w] += 1
assert sum(result["worker_cost"].values()) == pytest.approx(result["total_cost"])
for worker, cost in result["worker_cost"].items():
assert cost == pytest.approx(RATES[worker] * shift_counts[worker])
assert shift_counts[worker] > 0
def test_t3_shift_with_no_available_worker_is_infeasible():
"""T3: a shift nobody is available for makes the model infeasible."""
rates = {"Alice": 10, "Brian": 12}
requirements = {"ShiftA": 1, "ShiftB": 1}
availability = [("Alice", "ShiftA"), ("Brian", "ShiftA")] # ShiftB uncovered
result = run(rates=rates, requirements=requirements, availability=availability)
assert result["status"] == "infeasible"
def test_t4_single_worker_single_shift():
"""T4: minimal edge case -- one worker, one shift, cost equals their rate."""
rates = {"Solo": 15}
requirements = {"ShiftX": 1}
availability = [("Solo", "ShiftX")]
result = run(rates=rates, requirements=requirements, availability=availability)
assert result["status"] == "optimal"
assert result["total_cost"] == pytest.approx(15)
assert "Solo" in result["assignments"]["ShiftX"]
def test_t5_cheaper_worker_is_preferred():
"""T5: when both are available, the optimizer picks the cheaper worker."""
rates = {"Cheap": 5, "Pricey": 20}
requirements = {"ShiftY": 1}
availability = [("Cheap", "ShiftY"), ("Pricey", "ShiftY")]
result = run(rates=rates, requirements=requirements, availability=availability)
assert result["status"] == "optimal"
assert result["total_cost"] == pytest.approx(5)
assert "Cheap" in result["assignments"]["ShiftY"]
assert "Pricey" not in result["assignments"]["ShiftY"]
Generating code and tests is the easy half. Modeler also runs the suite in an isolated environment created fresh for this conversation, solving against Gurobi in the cloud — you do not need Gurobi installed locally, and you do not need a license of your own to see the model solve.
Yes, that looks right — please go ahead: finalize the full specification document, implement the gurobipy model, write the pytest test suite covering all the scenarios, run the tests, and report the full results including the optimal schedule and cost breakdown. Also, once that’s done, please derive and show me the full mathematical formulation.
The specification’s three constraints and the output contract are already fully pinned — the assignment variables, the staffing constraint and the cost breakdown all follow directly from the spec, with no open implementation detail left to ask about. Writing the implementation and the test suite now, then running both against the full worker and shift data.
The run
All 5 tests pass on the first run:
$ pytest test_workforce_modeler.py -q
.....
5 passed in 0.29s
A closer look at the Gurobi log shows why it resolves instantly:
Optimize a model with 14 rows, 72 columns and 72 nonzeros
Model fingerprint: 0x7b3a6088
Variable types: 0 continuous, 72 integer (72 binary)
Coefficient statistics:
Matrix range [1e+00, 1e+00]
Objective range [8e+00, 1e+01]
Bounds range [1e+00, 1e+00]
RHS range [2e+00, 7e+00]
Presolve removed 14 rows and 72 columns
Presolve: All rows and columns removed
Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)
Optimal solution found (tolerance 1.00e-04)
Best objective 4.580000000000e+02, best bound 4.580000000000e+02, gap 0.0000%
“Presolve removed 14 rows and 72 columns” — the model resolved entirely by presolve, with zero simplex iterations and zero branch-and-bound nodes. With only 72 binary variables and one equality constraint per shift, the assignment structure is simple enough that presolve alone finds and proves the optimum.
All tests pass. Here is a full summary of everything that was built:
Results
Optimal schedule — total labor cost $458:
Shift |
Workers assigned |
|---|---|
Mon1 |
Ed, Fred, Gu |
Tue2 |
Dan, Ed |
Wed3 |
Amy, Dan, Ed, Fred |
Thu4 |
Cathy, Ed |
Fri5 |
Amy, Cathy, Dan, Ed, Gu |
Sat6 |
Bob, Dan, Fred, Gu |
Sun7 |
Amy, Cathy, Ed |
Mon8 |
Dan, Ed |
Tue9 |
Dan, Ed |
Wed10 |
Amy, Cathy, Dan |
Thu11 |
Amy, Cathy, Dan, Ed |
Fri12 |
Amy, Cathy, Dan, Fred |
Sat13 |
Amy, Bob, Cathy, Dan, Ed, Fred, Gu |
Sun14 |
Amy, Cathy, Dan, Ed, Fred |
Per-worker cost breakdown:
Worker |
Shifts worked |
Rate |
Total cost |
|---|---|---|---|
Amy |
8 |
$10 |
$80 |
Bob |
2 |
$12 |
$24 |
Cathy |
8 |
$10 |
$80 |
Dan |
11 |
$8 |
$88 |
Ed |
11 |
$8 |
$88 |
Fred |
6 |
$9 |
$54 |
Gu |
4 |
$11 |
$44 |
The optimizer heavily favors Dan and Ed — both at $8, the cheapest rate — assigning each to 11 of the 14 shifts. Bob, the most expensive at $12, is used only twice, where no cheaper alternative was available.
Files created
File |
Description |
|---|---|
|
Complete problem specification |
|
Input availability dataset |
|
Gurobipy implementation ( |
|
Pytest suite covering scenarios T1–T5 |
Test results
Test |
Scenario |
Result |
|---|---|---|
T1 |
Baseline — optimal schedule, total cost 458 |
PASSED |
T2 |
Per-worker cost breakdown sums correctly |
PASSED |
T3 |
Shift with no available worker is infeasible |
PASSED |
T4 |
Minimal single-worker, single-shift case |
PASSED |
T5 |
Cheaper worker preferred when both available |
PASSED |
Let me know if you would like any changes, such as capping how many shifts a single worker can take, or generating the mathematical formulation.
That log lands in the workspace as execution.log alongside the code, so the
solve is auditable rather than asserted.
The loop ends one of two ways. Either every test passes, as here — or Modeler concludes a failure reflects a genuine contradiction between the specification, the implementation and the tests. In that case it stops instead of guessing, tells you what it found, and asks which side is the one to correct — that second path is what the Changing the model tab demonstrates.
What the cloud environment gives you, and what it does not
The environment is built for validating a model, not for running production
workloads. Python 3 is available with gurobipy, gurobipy-pandas,
numpy, pandas, scipy and pytest pre-installed. Within that:
Solves use a Gurobi trial license, capped at 2,000 variables and 2,000 constraints. This model, at 72 columns and 14 rows, is nowhere near the cap — but a full-scale roster with hundreds of workers and shifts could be.
Execution is limited to 60 seconds per command, with tight CPU and memory limits. This is why the generated tests use small hard-coded data and a short solver time limit.
Additional packages cannot be installed, there is no internet access, and Compute Server and Instant Cloud cannot be used.
So treat a passing suite as evidence that the model is right, not as a performance measurement. Download the implementation and run it against your own license and your own full roster for that. See Solving limits for the details.
Business problems do not hold still. The specification above puts worker shift limits, overtime pay and shift preferences all out of scope — reasonable simplifications, until the schedule itself reveals why they matter: Dan and Ed, the two cheapest workers, end up carrying 11 of the 14 shifts each. Ask for the change in plain language and Modeler updates the specification first, then propagates it to the implementation and the tests, in that order — three separate times here, since three separate requests came in over the course of the conversation.
Change 1 — capping how many shifts one worker can take
I want to make sure no single worker gets overloaded — please add a rule that no worker can be assigned to more than 8 shifts across the two-week period. Does that change the recommended schedule?
This moves the “worker shift limits” bullet from out of scope into the model as a new constraint, C4. It is a straightforward upper bound on total shifts per worker — no ambiguity to flag here, so I can update the specification and implementation directly and answer the “does it change the schedule” question from the re-solve.
Everything is updated and all 17 tests pass. Yes, the constraint changes the schedule:
def _add_constraints(self):
- """C1 -- Shift staffing requirement."""
+ """C1 -- Shift staffing requirement.
+ C4 -- Maximum shifts per worker."""
self.model.addConstrs(
(... staffing constraint ...),
name="staffing"
)
+
+ # C4: maximum shifts per worker
+ self.model.addConstrs(
+ (
+ gp.quicksum(
+ self._x[w, s]
+ for (w2, s) in self.availability if w2 == w
+ ) <= self.max_shifts
+ for w in self.workers
+ ),
+ name="max_shifts"
+ )
def test_t6_max_shifts_cap_enforced(self):
"""T6: no worker exceeds 8 shifts; cost >= unconstrained optimum (458)."""
result = solve_full_dataset(max_shifts=8)
assert result["is_optimal"] is True
assert all(wc["shifts_worked"] <= 8 for wc in result["worker_cost"].values())
assert result["objective_value"] >= 458
def test_t7_max_shifts_cap_too_tight_is_infeasible(self):
"""T7: max_shifts=1 cannot cover 50 required worker-shifts."""
result = solve_full_dataset(max_shifts=1)
assert result["is_optimal"] is False
Change 2 — guaranteeing Bob and Gu their weekend shifts
Bob and Gu have specifically asked for more weekend shifts (Sat6, Sun7, Sat13, Sun14) since those pay a bit better for their personal schedule reasons. Can you make sure they’re prioritized for weekend shifts?
“Prioritized” is underspecified — it could mean a hard guarantee, a soft objective bonus, or a strict fill-first rule, and each produces a different schedule and a different cost. Rather than picking one silently, I should lay out the readings and ask which one matches what Bob and Gu actually want.
“Prioritized” can mean a few different things in an optimization model, and the choice has a real impact on both the schedule and the cost. Before touching any code:
Which of these best reflects what Bob and Gu are asking for?
Let’s go with option 2 — a hard minimum. Bob and Gu should each be guaranteed at least 3 of the 4 weekend shifts they’re available for, as long as it stays feasible with the 8-shift cap.
Bob is only available for 2 of the 4 weekend shifts (Sat6, Sat13); Gu is available for all 4. A literal “at least 3” for Bob is infeasible on its own — not because of anything else in the model, but because he simply doesn’t have 3 weekend shifts to give. The floor needs a rule for that case, so I will cap the effective minimum at each worker’s own weekend availability rather than let an unreachable request fail the whole model.
All 24 tests pass. The weekend guarantee did not raise the cost at all here — the optimizer was already assigning Bob and Gu to their available weekend shifts to minimize cost, so the constraint mostly locks in what was already happening. Total cost stays at $472, the same as after Change 1. Bob requested 3 weekend shifts but is only available for 2 (Sat6, Sat13); his effective floor silently dropped to 2 so the model stays feasible. Gu’s floor of 3 is unaffected, since he is available for all 4.
Where it stops and asks
That silent drop for Bob is not free of ambiguity — it is a modeling choice made without asking, and it deserves a second look before it ships as if it were the only possible reading.
You mentioned that since Bob is only available for 2 of the 4 weekend shifts, you automatically capped his effective minimum from 3 down to 2. Does that silent-capping behavior create any ambiguity with how the existing test scenarios should be read? Specifically — is it unclear whether “guarantee weekend shifts” means (a) silently give them as many as are available, or (b) flag a guarantee that can’t be fully met rather than silently reducing it? Would the two readings produce different numbers for T8?
Yes — this is a real fork, not a hypothetical one, and T8 is exactly
where it bites. Reading (a) lowers Bob’s floor to 2 and solves
normally. Reading (b) would add the literal sum >= 3 over only 2
available pairs, which is structurally infeasible regardless of
anything else in the model. The test I already wrote encodes (a)
without ever stating that choice explicitly, so a reader assuming (b)
would expect T8 to report infeasible instead. This has to be named and
decided, not left implicit in code no one asked about.
This is a real ambiguity, and it produces different numbers for T8:
Reading |
T8 outcome |
Total cost |
|---|---|---|
|
Optimal, Bob on Sat6 + Sat13 |
$472 |
|
Infeasible |
None |
Which behavior do you want — should a request that outruns a worker’s own availability be silently capped, or should it fail loudly?
Let’s go with option (a) — silent cap. It’s the most operationally useful behavior and matches what we already have. Please update the specification’s C5 description and T8’s acceptance criteria to make this explicit so there’s no ambiguity for the next person who reads it.
This is the loop ending in its second way: not a bug to fix, but a genuine fork in what “guarantee” was supposed to mean, one where picking wrong would have meant T8 quietly asserting the opposite of what a careful reader expected. Modeler named both readings, showed the numbers each produces, and let the answer come from the user rather than from an unstated default.
# C4: maximum shifts per worker
self.model.addConstrs(...)
+
+ # C5: minimum weekend shifts per worker
+ # Effective minimum = min(requested, weekend shifts available to worker)
+ weekend_set = set(self.weekend_shifts)
+ for worker, requested_min in self.weekend_min_shifts.items():
+ available_weekend_pairs = [
+ (w, s) for (w, s) in self.availability
+ if w == worker and s in weekend_set
+ ]
+ effective_min = min(requested_min, len(available_weekend_pairs))
+ if effective_min > 0:
+ self.model.addConstr(
+ gp.quicksum(self._x[w, s] for (w, s) in available_weekend_pairs)
+ >= effective_min,
+ name=f"min_weekend_{worker}"
+ )
def test_t8_weekend_minimum_silently_caps_for_bob(self):
"""T8: Bob's floor of 3 caps to 2 (his weekend availability); Gu's
floor of 3 is applied as-is. No error; cost stays at 472."""
result = solve_full_dataset(max_shifts=8, weekend_min_shifts={"Bob": 3, "Gu": 3})
assert result["is_optimal"] is True
assert result["objective_value"] == pytest.approx(472)
assert "Bob" in result["assignments"]["Sat6"]
assert "Bob" in result["assignments"]["Sat13"]
def test_t9_weekend_minimum_combined_with_cap_is_infeasible(self):
"""T9: a weekend floor that IS achievable in isolation still fails
once the shift cap leaves no room for the required weekday shifts."""
result = solve_small_scenario(max_shifts=2, weekend_min_shifts={"W1": 2})
assert result["is_optimal"] is False
Change 3 — overtime pay
One more change: I want to introduce overtime pay. Any worker assigned to more than 6 shifts in the two-week period should earn 1.5x their normal per-shift rate for each shift beyond the 6th. Does this change the recommended schedule or cost?
Overtime interacts with the existing max-shifts cap (C4) in a way the request does not resolve on its own: does the 8-shift ceiling still apply with shifts 7-8 billed at 1.5x, or does overtime replace the cap entirely, or does the cap simply move? Each choice changes the model’s shape, so this needs a question rather than an assumption. The output format for reporting overtime separately is a second, independent choice worth asking in the same turn rather than guessing and having to redo it.
Before implementing, two quick clarifying questions — both have a direct impact on the model design and the numbers.
Q1: Does the existing 8-shift cap still apply alongside the overtime rule?
Q2: How should the per-worker cost breakdown report overtime?
regular_cost, overtime_cost and total_cost per worker.1. Keep the 8-shift cap — overtime only applies to shifts 7 and 8 within that ceiling. 2. Split into regular_cost, overtime_cost, and total_cost per worker.
Overtime as a hard piecewise cost linearizes cleanly with one continuous variable per worker — no binaries needed, since the cost slope only increases, so the solver will drive that variable to the correct value at optimality on its own. Implementing that, splitting the cost breakdown as requested, and re-solving.
All 30 tests pass. Yes, overtime significantly changes both the schedule and the cost — total cost rises from $472 to $513 (+$41, +8.7%).
Without overtime, Dan and Ed at $8/shift were the clear favorites — the model loaded them up to 8 shifts each with no penalty. With overtime at 1.5x beyond 6 shifts, their effective cost for shifts 7 and 8 rises to $12/shift — the same as Bob’s regular rate. The optimizer now has a reason to use Bob more heavily instead: he goes from 2 shifts to 6, at zero overtime, while Gu drops from 8 shifts to 6 to avoid his own overtime rate of $16.50 — the most expensive in the team.
Minimize the total labor cost across all shift assignments. Each
assignment within the regular threshold incurs the worker's flat
per-shift rate. Each assignment beyond the regular threshold incurs 1.5
times the worker's flat per-shift rate (overtime).
def _create_variables(self):
self._x = self.model.addVars(self.availability, vtype=GRB.BINARY, name="assign")
+
+ overtime_ub = max(0, self.max_shifts - self.overtime_threshold)
+ self._o = self.model.addVars(
+ self.workers, lb=0.0, ub=overtime_ub, name="overtime"
+ )
def _set_objective(self):
self.model.setObjective(
- gp.quicksum(self.cost[w] * self._x[w, s] for (w, s) in self.availability),
+ gp.quicksum(self.cost[w] * self._x[w, s] for (w, s) in self.availability)
+ + gp.quicksum(0.5 * self.cost[w] * self._o[w] for w in self.workers),
GRB.MINIMIZE
)
+ # C6: overtime linearization -- o[w] >= shifts worked beyond overtime_threshold
+ self.model.addConstrs(
+ (
+ self._o[w] >= gp.quicksum(
+ self._x[w, s] for (w2, s) in self.availability if w2 == w
+ ) - self.overtime_threshold
+ for w in self.workers
+ ),
+ name="overtime_lb"
+ )
def test_t11_two_shifts_beyond_threshold_billed_at_1_5x(self):
"""T11: 8 shifts at rate=10, threshold=6 -> regular=60, OT=30, total=90."""
result = solve_single_worker(shifts=8, rate=10, overtime_threshold=6)
wc = result["worker_cost"]["W"]
assert wc["overtime_shifts"] == 2
assert wc["regular_cost"] == pytest.approx(60)
assert wc["overtime_cost"] == pytest.approx(30)
def test_t12_overtime_favors_spreading_shifts_evenly(self):
"""T12: two equally-priced workers split 8 shifts 4/4 rather than
loading one past the threshold, since the even split has zero
overtime and is strictly cheaper."""
result = solve_two_equal_workers(overtime_threshold=4)
assert result["objective_value"] == pytest.approx(80)
assert all(wc["overtime_shifts"] == 0 for wc in result["worker_cost"].values())
Out of scope, updated. All three changes moved a bullet out of “out of scope” and into the model: worker shift limits (Change 1), shift preferences — the closest proxy the original spec had for a weekend request (Change 2), and overtime pay (Change 3). What remains out of scope is per-worker individual shift limits (a different cap per worker, rather than one global maximum), multi-week rolling schedules, and part-time or partial shift coverage.
The answer, across all three changes
Model version |
Total cost |
What changed |
|---|---|---|
Baseline (no cap, no weekend guarantee, no overtime) |
$458 |
Dan and Ed each carry 11 of 14 shifts. |
|
$472 |
Dan and Ed capped at 8; Fred and Gu absorb the rest. |
|
$472 |
No change — the optimizer already put them there to minimize cost. |
|
$513 |
Bob rises from 2 to 6 shifts; Gu drops from 8 to 6 to dodge his own $16.50 overtime rate. |
Nothing here required guessing at what “overloaded,” “prioritized,” or “extra pay” meant — each request either mapped onto one clear constraint (Change 1), or produced a moment where Modeler named the readings and asked (Changes 2 and 3’s Q1/Q2), which is what a specification-first workflow is for.
The specification, the implementation and the tests never mention mathematics — that is deliberate, so the business owner can read them. But you may still need the formulation: to review the model, to document it, or to hand it to someone who thinks in constraints rather than in code. Ask at any point and Modeler derives one from the current specification and implementation, in Markdown with LaTeX, and saves it to the workspace with everything else.
Sets and indices
\(w \in W\): workers.
\(s \in S\): shifts.
\(S^{WE} \subseteq S\): weekend shifts.
\(P \subseteq W\): workers with a minimum weekend-shift guarantee.
\(A \subseteq W \times S\): valid (worker, shift) availability pairs.
\(W_s = \{w \in W : (w,s) \in A\}\): workers available for shift \(s\).
\(S_w = \{s \in S : (w,s) \in A\}\): shifts worker \(w\) is available for.
\(A^{WE}_w = \{s \in S^{WE} : (w,s) \in A\}\): weekend shifts worker \(w\) is available for.
Parameters
\(c_w\): flat cost per shift worked for worker \(w\).
Amy 10, Bob 12, Cathy 10, Dan 8, Ed 8, Fred 9, Gu 11
\(r_s\): number of workers required for shift \(s\).
\(M\): maximum shifts any worker may be assigned (8).
\(\hat{g}_w\): requested minimum weekend shifts for worker \(w \in P\).
\(g_w = \min(\hat{g}_w, |A^{WE}_w|)\): effective minimum weekend shifts, capped at that worker’s own weekend availability.
Bob: requested 3, available 2 (Sat6, Sat13 only), effective 2
Gu: requested 3, available 4 (all weekends), effective 3
Decision variables
\(x_{ws} \in \{0, 1\}\) for \((w, s) \in A\): 1 if worker \(w\) is assigned to shift \(s\). Variables are only defined over the valid availability pairs, implicitly enforcing C2.
Objective function
Minimize the total labor cost across all shift assignments:
Constraints
C1 — Shift staffing requirement. Every shift \(s \in S\) must be assigned exactly \(r_s\) workers:
C2 — Availability (implicit — \(x_{ws}\) is defined only for \((w,s) \in A\)).
C3 — Binary assignment.
C4 — Maximum shifts per worker. No worker may be assigned to more than \(M\) shifts across the full scheduling period:
C5 — Minimum weekend shifts per worker. For each worker \(w \in P\) with a weekend-shift guarantee:
where \(g_w = \min(\hat{g}_w, |A^{WE}_w|)\) ensures the constraint is always feasible in isolation — a request that outruns one worker’s own weekend availability is silently capped rather than making the model infeasible for that reason alone.
Complete model
Additional notes
This is a Binary Integer Program with \(|A| = 72\) binary variables, 14 equality constraints (C1), 7 upper-bound constraints (C4), and 2 lower-bound constraints (C5).
The effective minimum \(g_w = \min(\hat{g}_w, |A^{WE}_w|)\) is computed before the model is built. This prevents C5 from being trivially infeasible due to insufficient weekend availability.
Adding C4 and C5 on top of C1–C3 can only raise the optimum, never lower it: \(Z^*_{C1+C4+C5} \geq Z^*_{C1+C4} \geq Z^*_{C1} = \$458\).
Feasibility condition for C4. A necessary condition is \(\sum_{w \in W} \min(M, |S_w|) \geq \sum_{s \in S} r_s\). For the full dataset with \(M = 8\): capacity 55 \(\geq\) 50 (total demand). With \(M = 1\): capacity \(\leq 7 < 50\), making the problem infeasible — exactly what scenario T7 checks.
Note that this formulates the model as it stood after Changes 1 and 2 in the Changing the model tab (the maximum-shifts cap C4 and the weekend guarantee C5), before Change 3 added overtime pay. Overtime introduces a continuous variable \(o_w\) per worker and an extra \(0.5 \, c_w \, o_w\) term in the objective — a piecewise-linear cost that is tracked in the Changing the model tab’s code diff rather than folded into this formulation.
### Sets and indices
| Symbol | Description |
|--------|-------------|
| $W$ | Set of workers, indexed by $w$ |
| $S$ | Set of shifts, indexed by $s$ |
| $S^{WE} \subseteq S$ | Subset of weekend shifts |
| $P \subseteq W$ | Subset of workers with a minimum weekend-shift guarantee |
| $A \subseteq W \times S$ | Set of valid (worker, shift) availability pairs |
| $W_s = \{w \in W : (w,s) \in A\}$ | Workers available for shift $s$ |
| $S_w = \{s \in S : (w,s) \in A\}$ | Shifts worker $w$ is available for |
| $A^{WE}_w = \{s \in S^{WE} : (w,s) \in A\}$ | Weekend shifts worker $w$ is available for |
**Instantiation (full two-week dataset):**
$$W = \{\text{Amy, Bob, Cathy, Dan, Ed, Fred, Gu}\}$$
$$S = \{\text{Mon1, Tue2, Wed3, Thu4, Fri5, Sat6, Sun7, Mon8, Tue9, Wed10, Thu11, Fri12, Sat13, Sun14}\}$$
$$S^{WE} = \{\text{Sat6, Sun7, Sat13, Sun14}\}$$
$$P = \{\text{Bob, Gu}\}$$
### Parameters
| Symbol | Type | Description |
|--------|------|-------------|
| $c_w \in \mathbb{R}_{+}$ | float | Flat cost per shift worked for worker $w \in W$ |
| $r_s \in \mathbb{Z}_{+}$ | integer | Number of workers required for shift $s \in S$ |
| $M \in \mathbb{Z}_{+}$ | integer | Maximum number of shifts any single worker may be assigned (8) |
| $\hat{g}_w \in \mathbb{Z}_{+}$ | integer | Requested minimum weekend shifts for worker $w \in P$ |
| $g_w \in \mathbb{Z}_{+}$ | integer | Effective minimum weekend shifts: $g_w = \min(\hat{g}_w,\; \lvert A^{WE}_w \rvert)$ |
**Worker cost rates:**
| $w$ | $c_w$ |
|-----|-------|
| Amy | 10 |
| Bob | 12 |
| Cathy | 10 |
| Dan | 8 |
| Ed | 8 |
| Fred | 9 |
| Gu | 11 |
**Weekend guarantees (with effective minimums):**
| $w$ | $\hat{g}_w$ (requested) | $\lvert A^{WE}_w \rvert$ (available) | $g_w$ (effective) |
|-----|:-----------------------:|:------------------------------------:|:-----------------:|
| Bob | 3 | 2 (Sat6, Sat13 only) | 2 |
| Gu | 3 | 4 (all weekends) | 3 |
Bob is not available for Sun7 or Sun14, so his effective minimum is capped at
2 to preserve feasibility.
### Decision variables
$$x_{ws} \in \{0, 1\} \qquad \forall\, (w, s) \in A$$
$$x_{ws} = \begin{cases} 1 & \text{if worker } w \text{ is assigned to shift } s \\ 0 & \text{otherwise} \end{cases}$$
Variables are only defined over the valid availability pairs $A$, implicitly
enforcing constraint C2.
### Objective function
Minimize the total labor cost across all shift assignments:
$$
\min \quad Z = \sum_{(w,\,s)\,\in\, A} c_w \cdot x_{ws}
$$
Equivalently, as a sum of per-worker costs:
$$Z = \sum_{w \in W} c_w \sum_{s \in S_w} x_{ws}$$
### Constraints
**C1 — Shift Staffing Requirement.** Every shift $s \in S$ must be assigned
exactly $r_s$ workers:
$$\sum_{w \in W_s} x_{ws} = r_s \qquad \forall\, s \in S$$
**C2 — Availability** (implicit). Worker $w$ can only be assigned to shift
$s$ if $(w, s) \in A$, enforced structurally by restricting variables to $A$.
**C3 — Binary Assignment.**
$$x_{ws} \in \{0, 1\} \qquad \forall\, (w, s) \in A$$
**C4 — Maximum Shifts Per Worker.** No worker may be assigned to more than
$M$ shifts across the full scheduling period:
$$\sum_{s \in S_w} x_{ws} \leq M \qquad \forall\, w \in W$$
**C5 — Minimum Weekend Shifts Per Worker.** For each worker $w \in P$ with a
weekend-shift guarantee, the number of weekend assignments must meet the
effective minimum $g_w$:
$$\sum_{s \in A^{WE}_w} x_{ws} \geq g_w \qquad \forall\, w \in P$$
where $g_w = \min(\hat{g}_w,\; \lvert A^{WE}_w \rvert)$ ensures the constraint
is always feasible in isolation — a request that outruns one worker's own
weekend availability is silently capped rather than making the model
infeasible for that reason alone.
### Complete model
$$
\begin{aligned}
\min \quad & \sum_{(w,\,s)\,\in\, A} c_w \cdot x_{ws} \\[8pt]
\text{s.t.} \quad
& \sum_{w \in W_s} x_{ws} = r_s
& \forall\, s \in S
& \quad \text{(C1)} \\[6pt]
& \sum_{s \in S_w} x_{ws} \leq M
& \forall\, w \in W
& \quad \text{(C4)} \\[6pt]
& \sum_{s \in A^{WE}_w} x_{ws} \geq g_w
& \forall\, w \in P
& \quad \text{(C5)} \\[6pt]
& x_{ws} \in \{0,1\}
& \forall\, (w,s) \in A
& \quad \text{(C2, C3)}
\end{aligned}
$$
### Additional notes
- This is a Binary Integer Program (BIP) with $|A| = 72$ binary variables,
$|S| = 14$ equality constraints (C1), $|W| = 7$ upper-bound constraints
(C4), and $|P| = 2$ lower-bound constraints (C5).
- The effective minimum $g_w = \min(\hat{g}_w, |A^{WE}_w|)$ is computed
before the model is built. This prevents C5 from being trivially
infeasible due to insufficient weekend availability — in the current
dataset, Bob's requested minimum of 3 is reduced to 2 because he is
available for only 2 weekend shifts.
- Adding C4 and C5 on top of C1-C3 can only raise the optimum, never lower
it:
$$Z^*_{C1+C4+C5} \geq Z^*_{C1+C4} \geq Z^*_{C1} = \$458$$
- **Feasibility condition for C4.** A necessary condition is
$\sum_{w \in W} \min(M, |S_w|) \geq \sum_{s \in S} r_s$. For the full
dataset with $M = 8$: capacity $= 55 \geq 50$ (total demand). With
$M = 1$: capacity $\leq 7 < 50$, making the problem infeasible — exactly
what scenario T7 checks.
Note that this formulates the model as it stood after Changes 1 and 2 in the
**Changing the model** tab (the maximum-shifts cap C4 and the weekend
guarantee C5), before Change 3 added overtime pay. Overtime introduces a
continuous variable $o_w$ per worker and an extra $0.5 \, c_w \, o_w$ term in
the objective — a piecewise-linear cost, not one that fits cleanly into this
binary-assignment formulation without that addition, which is why it is
tracked separately in the **Changing the model** tab rather than folded in
here.