A dispatch policy should sort candidates, not filter them
Every dispatcher grows an extension point sooner or later. One operation wants the next job to go to the least loaded courier, another to whoever has been on duty longest, and a third wants a rule nobody has written yet. The obvious shape for that extension point is a hook that picks the courier, and that shape is wrong. A hook that can pick can also reject, and a hook that can reject can also accept something the depot's rules never allowed.
We draw the line somewhere else in shift-planner, our library for planning field courier shifts (https://github.com/shipmindlabs/shift-planner). Eligibility is fixed code inside the dispatcher. The plug-in gets exactly one method, and that method returns a sort key over the shifts that already passed.
Two kinds of rule, two kinds of failure#
The distinction worth drawing is about cost, not about taxonomy. A preference that is wrong costs you a worse choice: a courier who was slightly more loaded than the one you would have picked takes the job, and the day comes out marginally less balanced. A constraint that gets bypassed costs you a courier sent to the wrong depot, handed a job past the capacity of their shift, or called again ten minutes after they declined one and were put on a cooldown.
Those two failures differ in size, but what matters more is that they differ in when they show up. If you implement a constraint as a strong preference (rank cooling-down couriers last, rank shifts with no capacity left last) the system behaves correctly for as long as somebody better is available. The moment the shortlist is down to a single candidate, the preference has nothing left to prefer and the violation lands. That is lunchtime, or a storm, or exactly the hour the whole plan exists for. A constraint expressed as a preference is a rule that switches itself off precisely when load is highest.
So what you ask of any plug-in point is not "what does it do when it is written correctly" but "what is the worst a wrong one can do". We want that answer to be a bad ordering, and nothing else.
The filter is fixed code#
Dispatcher.candidates builds the eligible list itself. A shift survives three checks, and all three are about the job rather than about preference:
def candidates(
self, job: Job, taken: Mapping[str, int] | None = None
) -> tuple[Candidate, ...]:
loads = taken or {}
eligible = [
Candidate(shift, loads.get(shift.worker, 0))
for shift in self.roster
if shift.location == job.location
and shift.covers(job.at)
and not self.cooldowns.locked(shift.worker, job.at)
]
return tuple(
sorted(
(candidate for candidate in eligible if candidate.spare_capacity > 0),
key=self.policy.sort_key,
)
)
def assign(
self, job: Job, taken: Mapping[str, int] | None = None
) -> Candidate | None:
ordered = self.candidates(job, taken)
return ordered[0] if ordered else NoneThree details in that list carry most of the argument.
The location check is an equality, not a distance. A policy cannot quietly widen it into "nearest depot", because a shift that fails the comparison never becomes a Candidate at all.
covers is the shift's half-open interval test, start <= moment < end, applied to the job's moment rather than to the dispatcher's. No moment in this library comes from the wall clock; every check takes the moment as an argument.
The cooldown gets read at job.at too. A courier who declined a job is held back over the half-open interval [since, until) recorded on the CooldownBoard, and that lock lifts by the passing of time rather than by anybody clearing it. Asking the board about the job's own moment is what makes a replay of yesterday's job stream produce yesterday's answers.
Capacity is the fourth check, and it happens after the list is built, because it depends on something the dispatcher does not own: the load. Candidate.taken is read from the caller's mapping, an absent worker counting as zero, and spare_capacity is max(0, self.shift.capacity - self.taken). A candidate with no spare capacity gets dropped before any ordering happens. Only then does sorted run, and assign returns ordered[0] when the tuple is non-empty and None otherwise.
None is the correct answer to "nobody is eligible". It is also the answer no pick-shaped hook can be trusted to give, because a hook asked to pick one will pick one.
The policy is a sort key and nothing else#
class AssignmentPolicy(Protocol):
"""Orders the candidates for a job; the first one gets it."""
def sort_key(self, candidate: Candidate) -> tuple[Any, ...]:
...
@dataclass(frozen=True)
class LeastLoaded:
"""Prefers the courier with the fewest jobs so far, then the earlier shift."""
def sort_key(self, candidate: Candidate) -> tuple[Any, ...]:
return (candidate.taken, candidate.shift.start, candidate.worker)
@dataclass(frozen=True)
class EarliestStart:
"""Prefers the courier who has been on duty the longest."""
def sort_key(self, candidate: Candidate) -> tuple[Any, ...]:
return (candidate.shift.start, candidate.taken, candidate.worker)The Protocol is the whole guarantee. A function from a Candidate to a tuple can reorder what survived the filter. It cannot admit a shift the filter rejected, because it is never shown one. It cannot remove a shift the filter passed, because sorting a sequence returns a permutation of it, and the length does not change whatever the key says.
That bound is worth testing against hostile input rather than reasonable input. Here is a policy nobody should ship, sitting next to one that is a legitimate change of emphasis:
@dataclass(frozen=True)
class MostSpareCapacity:
"""Prefers the courier with the most room left, then the earlier shift."""
def sort_key(self, candidate: Candidate) -> tuple[Any, ...]:
return (-candidate.spare_capacity, candidate.shift.start, candidate.worker)
@dataclass(frozen=True)
class Perverse:
"""Deliberately bad: hands the job to the busiest eligible courier."""
def sort_key(self, candidate: Candidate) -> tuple[Any, ...]:
return (-candidate.taken, candidate.worker)Perverse is about the worst policy anyone can write inside the rules, and the damage it does is this: the busiest eligible courier gets the job. Still at the right location. Still on duty at the job's moment. Still inside their capacity. Still not cooling down.
That is what makes the extension point cheap to review. A reviewer argues about whether a policy expresses a sensible preference, which is an operations judgement, instead of re-deriving whether it is safe, which would be a correctness judgement repeated on every plug-in anyone ever writes.
The key ends in a name#
Both built-in policies end their key with candidate.worker, and that last element does more work than you would guess.
Python's sort is stable, so without a final discriminator an exact tie on the leading fields gets settled by the order in which the candidates arrived. In this library that order comes from Roster, which normalises its shifts to a tuple sorted by (start, end, worker). It is deterministic today, and it is still the wrong thing to lean on: it is a detail of how a different class stores its data, and an assignment that quietly depends on it changes behaviour the day that storage changes, with no test anywhere pointed at the thing that moved.
Ending the key with the worker name makes the tie-break a property of the candidates themselves. It also makes the key total, and it is the roster that earns that: two shifts of one worker may never share time. At most one shift per worker can cover job.at, so within a single job's candidate list every worker name appears exactly once. No residual tie is left for arrival order to decide. The same roster, the same taken mapping and the same moment produce the same assignment on every run, in a test, on a laptop, and in production.
This kind of determinism is not an aesthetic preference. It is what lets you argue about a dispatch decision after the fact. When an operations lead asks why this courier and not that one, the answer is a tuple comparison over values that were all passed in as arguments.
What it costs to run#
Three things stay your job as the caller, and they are the three that bite.
The load mapping is yours. Nothing in the library ever increments it. Dispatcher.assign reads taken and returns a Candidate; it does not record that the job was handed out, because it does not know whether your transaction committed. In a batch loop you update the mapping yourself, or LeastLoaded will hand every job in the batch to the same courier, each time correctly, each time on the same stale count.
dispatcher = Dispatcher(roster, policy=MostSpareCapacity())
taken: dict[str, int] = {}
for job in jobs:
chosen = dispatcher.assign(job, taken)
if chosen is None:
continue
taken[chosen.worker] = taken.get(chosen.worker, 0) + 1Refusals work the same way. The board is immutable, so recording one returns a new dispatcher instead of mutating the one you held:
dispatcher = dispatcher.refuse("anna", job.at)And candidates walks the whole roster on every call. For a roster drawn over a long horizon that is a linear scan you pay per job, so dispatch against the slice you actually need rather than the full plan.
Close#
The rule we apply when a new dispatch rule turns up in review is short. If getting it wrong sends a courier somewhere they must not go, it belongs in the filter, in fixed code, where there is exactly one copy of it. If getting it wrong only costs a worse choice, it belongs in the key. And whatever the key contains, it ends with a name, so that the candidates, and not the order they happened to arrive in, settle the last tie.