DEV Community

Kingsley Onoh
Kingsley Onoh

Posted on • Originally published at kingsleyonoh.com

Why I made OR-Tools prove it was better than the deterministic dispatcher

Dispatch optimization needs a lower bound before it needs a clever objective.

In the first real OR-Tools integration, the solver selected fewer assignments than the deterministic fallback it needed to improve. That result made the boundary explicit: CP-SAT could optimize cost, priority, and tie-breakers only after it matched or beat the deterministic feasible assignment count.

The constraint changed how I treated OR-Tools inside the dispatch engine. I had treated the solver as the smarter engine in the room. The code reminded me that dispatch combines math with an operating record. A plan has timestamps, frozen work, post-selection capacity checks, replay metrics, and explanations a dispatcher can defend after the board changes.

The tempting version

The tempting version is simple. Build one boolean variable per eligible technician-job decision. Add constraints for job uniqueness, technician capacity, planning windows, and frozen work. Maximize the objective. Return the result.

That version reads well in a design doc. It is also too trusting for dispatch.

A field-service board has commitments. A dispatcher accepts a plan. A technician starts driving. A supervisor freezes a job. A customer is waiting against an SLA clock. If the solver returns an answer that is mathematically feasible but operationally worse, the system still has to notice.

The code that changed the contract

The final adapter runs deterministic solving first, uses that count as a lower bound, and then lets CP-SAT optimize within that boundary.

val vars = linearArgs(decisions.map(_.variable))
val deterministicAssignmentCount = fallback.solve(model, options).assignments.size
if (deterministicAssignmentCount > 0) {
  val _ = cp.addGreaterOrEqual(LinearExpr.sum(vars), deterministicAssignmentCount.toLong)
}
val coeffs = decisions.map { decision =>
  val assignmentReward = 1_000_000L - (decision.job.priority.rank.toLong * 10_000L)
  val cost = (decision.cost.total * BigDecimal(100))
    .setScale(0, BigDecimal.RoundingMode.HALF_UP)
    .toLong
  assignmentReward - cost - jobIndex(decision.job.id).toLong * 100L -
    technicianIndex(decision.technician.id).toLong
}.toArray
cp.maximize(LinearExpr.weightedSum(vars, coeffs))
Enter fullscreen mode Exit fullscreen mode

That sample is from OrToolsSolverAdapter.solveWithCpSat. The assignment reward is intentionally large. Priority affects the reward. Cost is scaled to an integer. Job and technician indexes act as stable tie-breakers.

The line that matters most is not the maximize call. It is cp.addGreaterOrEqual(LinearExpr.sum(vars), deterministicAssignmentCount.toLong). That line says the solver is allowed to optimize, but it is not allowed to schedule less work than the deterministic feasible path already found.

Why deterministic sequencing stayed

Even after CP-SAT selects decisions, the system does not blindly stamp them into the board. It passes selected decisions through deterministic scheduling. That second stage can still reject work for capacity or planning-window overflow.

At first, that felt redundant. If the solver has constraints, why check again?

Because the dispatch plan is not only a set of pairs. It is a sequence of visits with concrete start times, travel, overtime, and explanation codes. Stable timestamps matter for replay. Stable rejection reasons matter for support. The deterministic layer turns selected pairs into an operating plan that looks the same when the same input snapshot is replayed.

That also protects partial plans. A solver timeout or infeasible slice should not fabricate certainty. The domain has reason codes such as missing_capability, frozen_assignment, capacity_exceeded, outside_planning_window, and solver_timeout. A partial plan with honest unscheduled work is safer than a complete-looking plan built on silence.

Frozen work was the real domain invariant

The solver failure was loud because it affected assignment count. Frozen work is quieter and more dangerous.

The constraint builder treats accepted, completed, and frozen assignments as hard facts. A technician who conflicts with frozen work is rejected. A job that would collide with preserved work does not get moved just because the global objective improves.

That choice is easy to miss if you only look at optimization. A solver optimizes variables. Dispatchers manage promises. Once a human has accepted work, the board has a memory. The optimizer has to respect that memory.

What surprised me

The surprise was not that OR-Tools needed constraints. That is normal. The surprise was that the deterministic implementation became a guardrail for the solver rather than dead code waiting to be deleted.

I kept it for three reasons.

First, it gives the CP-SAT model a feasible assignment lower bound. Second, it gives the app a fallback when native solver loading, runtime failure, or timeout happens. Third, it gives replay a baseline that operators can compare against using SLA hit rate, travel minutes, overtime minutes, churn moves, unscheduled jobs, and solve time.

That makes the deterministic path part of the product, not a temporary scaffold.

The tradeoff

The cost is extra machinery. There are two solve paths. There is trace metadata. There are post-selection checks. There are tests that assert OR-Tools was invoked, no fallback happened, and deterministic results still match where they should.

The benefit is that optimization no longer gets special trust. It has to earn its place inside the operating record.

That is the lesson I took from this build: in systems that move real work, a smarter algorithm is not automatically the source of truth. Sometimes the older deterministic code is the witness that keeps the new optimizer honest.

Top comments (0)