← writing

2026-07-04

Scheduling a Retail Floor with CP-SAT: An Honest Shift-Optimization Architecture

sources: shift-solver-api/solver/cp_sat_scheduler.py (solve_sequential, 1,847 lines) · shift-solver-api/api/main.py (/solve, /solve/area-based) · shift-solver-api/models/domain.py (half-hour breaks, blocking tasks) · shift-solver-api/solver/capacity_planner.py (discrete n→capacity table) · shift-solver-api/parsers/backtest.py (overlap metrics) · shift-solver-api/docs/agents/BASELINE.md (phase-by-phase scores on real 2026-06-18 data) · shift-solver-api/.data/competency_2026_06_18.json (39-person real competency snapshot) · shift-solver-api/requirements.txt (ortools==9.11.4210) · shift-solver-api/railway.toml · PUSULA TDD p.1-4, sections 04/07/10

I work part-time on a ZARA sales floor, and there is a pattern you can see with your own eyes during the evening peak: between 5 and 7 pm, traffic is at its highest and conversion is at its lowest. The store is full and the outcome is worst. The usual explanation isn't laziness or understaffing — it's placement. The right people are standing in the wrong zones.

For the Inditex AI Hackathon 2026, I participated with PUSULA (COMPASS in the English deck), a people-development and shift-placement platform for store coaches. I didn't win — the winning project was Store Pulse — but the piece of PUSULA I want to write about here is the one I'm proudest of technically: the placement engine, built on Google OR-Tools CP-SAT.

And there's a part of this story the hackathon deck couldn't contain, because it happened afterwards: the solver didn't stay a demo. It's the system we use on the floor today. I'll get to that at the end.

The problem, stated precisely

Classic scheduling tools answer "who works which shift?" and stop there. They satisfy the rules but never see the result on the floor. The question I wanted to answer is one level deeper: for every (role, hour) cell in the day, which specific person should be there?

That's an assignment problem over a grid of people × roles × hours, for roughly 30 employees, and it's exactly the shape constraint programming was built for.

The model: x[p, r, t] ∈

The core of cp_sat_scheduler.py is a binary decision variable for every feasible triple:

# x[p, r, t] == 1  <=>  person p works role r during hour t
x = {}
for p in people:
    for r in roles:
        for t in hours:
            x[p, r, t] = model.NewBoolVar(f"x_{p}_{r}_{t}")

Everything else is constraints and costs over these variables.

Hard constraints — things that are never traded away:

  • Exact coverage: every role-hour cell gets exactly one person. Not "at least one," not "hopefully one."
  • Single location: a person is in at most one role in any given hour. Obvious to a human, mandatory to state to a solver.
  • Availability: no assignments outside a person's actual shift. The solver plans within the roster it is given; it doesn't invent working hours.
  • Competence floor: a person whose level for a role is "YOK" (none) can never be assigned to it, no matter how convenient.

Soft constraints — folded into a single cost function the solver minimizes:

  • Competence cost: assigning someone at 3 stars or above costs 0; the cost climbs as competence drops, down to 30 for a 1-star assignment. The solver can place a weak fit, but it pays for it.
  • Fatigue penalty: three or more consecutive hours in the same role gets penalized. Fresh eyes matter on a shop floor.
  • Fairness: workload imbalance across people is penalized, so the solver can't ride its two best people all day.
  • Development bonus: for people in the NEW group, being placed in a role they're still learning earns a negative cost — the solver is nudged to create practice opportunities instead of always optimizing for today.

That last one is the most opinionated modeling decision in the system. A pure efficiency objective quietly starves new people of growth. Making development a first-class term in the objective means the trade-off is explicit and tunable, not accidental.

Domain rules that came from the floor, not a textbook

The competence scale has five levels — YOK, KRIZ, DESTEK, ANA, TERCIH_PLUS (none / crisis-only / support / core / preferred-plus) — and staff fall into groups: CORE, FULL_JOKER, NEW, MANAGER. On top of that sits the buddy rule: a NEW person is never placed without an experienced colleague alongside them. That rule exists because I've watched what happens to a new starter left alone in a busy zone. No optimization gain justifies it.

I want to be honest about where these encodings come from: they're my formalization of how our floor actually runs, not an official Inditex specification.

Why CP-SAT and not something else

I considered the usual alternatives. LP solvers via PuLP handle continuous relaxations well but this problem is purely combinatorial. Gurobi is excellent and expensive; for a hackathon project by a part-timer, licensing is a real constraint, not a footnote. Genetic algorithms would have "worked" but give you no notion of optimality — you get an answer, never a statement about how good it is.

CP-SAT is open source, is built specifically for integer/boolean problems, chews through thousands of binary variables in seconds at this problem size, and — the part I value most — distinguishes OPTIMAL from FEASIBLE in its status output. When the solver says optimal, that's a claim I can repeat to a coach with a straight face.

Serving it: a small, single-purpose service

The solver core is wrapped in a FastAPI app (FastAPI 0.115, Pydantic 2.9, Uvicorn) with a deliberately small surface: POST /solve is the workhorse, POST /solve/area-based is a deterministic v2 path where each person stays in their home area, and /health keeps Railway's healthcheck happy. I'll be precise about the edges of that claim: /validate exists but is still a stub, and backtesting lives as an offline module (parsers/backtest.py) rather than an endpoint. The frontend talks to the service over plain HTTP with JSON; the solver knows nothing about the UI.

Deployment is Railway with the NIXPACKS builder, a uvicorn start command, and /health as the healthcheck. Nothing clever — a small Python service that does one thing. I've come to think that's the right size for an optimization engine: isolated enough to test with plain JSON in and JSON out.

Validation: backtesting against real human decisions

The uncomfortable question for any scheduler is "says who?" My answer was a backtest: run the solver over ten past periods and compare its output against the decisions the humans actually made.

The result was roughly 64% overlap with existing decisions, plus a 79/100 score from a rule-based self-evaluation module (self_evaluator.py) that checks the output against the domain rules.

I find 64% more interesting than 95% would have been. Near-total agreement would mean the solver is an expensive way to photocopy the status quo. Near-zero agreement would mean the model doesn't understand the floor. Two-thirds overlap says: the solver mostly agrees with experienced humans, and where it diverges, there's a concrete, inspectable suggestion to argue about. To be clear about what this is not: it's a retrospective consistency check, not evidence of improved store outcomes. I don't have a live trial, and I won't pretend the backtest is one.

The human stays in the loop — structurally, not decoratively

The solver's output is a suggestion, never an instruction. In PUSULA, the coach approves each placement individually, edits it, or rejects it with a reason — and a justified rejection is written back into the system as signal. Every suggestion carries a four-step provenance chain: the signal it came from, the evidence channel, the inference step, and an honest confidence band.

This isn't UX politeness. A scheduler that can't explain itself gets overridden silently, and then you're maintaining an expensive random-number generator. Explainability is the precondition for the tool being used at all.

It didn't stay a hackathon

PUSULA was a hackathon idea, and hackathon ideas usually end when the demo does. This one didn't. The placement engine became shift-solver-api, a standalone service behind a small Next.js frontend, and it's what we actively use to plan the women's section where I work. The daily organization of that section — a roughly 10-hour floor day for a team of around 42 people — used to take about two hours of manual chart-building; the solver brought that down to minutes, and it's still in use today. Those operational figures are self-reported: I'm the person who runs the system, and there's no external audit behind them. But the repo carries its own kind of evidence — a real 39-person competency snapshot dated June 18, 2026, dated floor decisions threaded through the code comments from May and June, and a regression baseline (docs/agents/BASELINE.md) that tracks the quality score climbing from 83.6 to 89.2 on real roster data as the constraints got refined.

Production also forced the single most instructive engineering change in the project. The monolithic model — all 12 hours solved at once — would stall at FEASIBLE with ~30 people on Railway's modest CPUs, which meant nondeterministic, occasionally ugly rosters depending on how far the solver got before the time limit. The fix in solve_sequential() was to solve each hour as its own tiny model, which reaches proven OPTIMAL in milliseconds, and to inject the cross-hour rules — rotation limits, cabin-family caps, cumulative fairness, closing-hour stability — from the hours already solved. The whole day now solves deterministically in a second or two, on any hardware. I gave up global optimality across the day and got back determinism, speed, and per-hour optimality; on a real floor, that trade is not close.

And real use kept feeding the model things no textbook formulation mentions: half-hour shift starts and breaks (a person present for only half a slot is exported as "Name 1/2", and if a critical cell like the fitting room is covered only by a half-present person, the solver emits an explicit handover warning with a suggested bridge instead of pretending the cell is covered); blocking HR and training tasks that remove someone from the floor for an hour; a discrete capacity table that says exactly how many people each role gets for every headcount from 2 to 17+; and Turkish-character name matching, because "İrem" and "Irem" silently failing to match is the kind of bug that only exists in production. Even the output stayed humble on purpose — an Excel export that reproduces the store's existing paper chart format, colors and MOLA row included, so adopting the tool cost the team nothing.

What I'd tell someone building one of these

  • Model the floor you have, not the one in the paper. The buddy rule and the development bonus came from watching shifts, and they matter more to the output quality than any solver parameter.

  • Hard constraints are a promise. Breaks, capacity, competence floors — the moment you let the objective function bargain with them, trust is gone.

  • Prefer auditable over adaptive. I deliberately chose rule-based inference plus constraint programming over anything learned end-to-end. In an environment where a wrong placement has an immediate human cost, "learning but auditable" beats "learning but opaque."

  • Backtest before you demo. The 64% number cost me a day and gave the whole project its credibility.

  • Production is the best constraint generator. Half-hour bridges, blocking tasks, Turkish name normalization — none of these were in the hackathon version, and all of them came from actually using the thing.

The hackathon didn't end with a win. It ended with something I'd now trade a trophy for without hesitating: a solver I can defend line by line, running as a real service, checked against ten periods of real decisions — and used, every day, by the floor it was built for.