The problem with endpoint scoring
The first version of this scoring worked on endpoints. Take the fare, the trip distance, the pickup distance, the destination postcode family, and decide. It is the obvious model and it is what most offer calculators do.
It is also wrong in a specific, repeatable way, and the failure is invisible in the data it looks at:
Endpoints describe where a job starts and finishes. They say nothing about the forty minutes in between. Two trips with identical fares, identical distances and identical destination postcodes can differ by twenty minutes of actual driving because one of them goes through a corridor that reliably jams at that hour.
So the engine had to answer a different question: what does this route actually pass through?
Evidence, not assumptions
The tempting fix is a congestion API or a static zone map. Both were rejected. A generic congestion feed describes traffic in the abstract; it does not know that one particular pull-out is bad specifically for a driver trying to turn right at 5pm. Static zone heuristics are worse — they are guesses with the confidence of rules.
Instead the system collects its own evidence. A second iOS Shortcut, deliberately kept to
two taps so it can be used while working, captures a GPS snapshot and records it as either
a traffic or no_traffic observation. Each
event is reverse-geocoded, reduced to postcode, outcode and sector, and stamped with a time
bucket.
| Bucket | Hours |
|---|---|
| night | 00:00 – 06:00 |
| morning | 06:00 – 11:00 |
| midday | 11:00 – 16:00 |
| evening | 16:00 – 24:00 |
Four buckets rather than twenty-four hours, because the database has to become useful early. Hourly resolution spreads sparse observations so thin that nothing reaches significance for months. Coarse buckets mean a handful of evenings in one area is already a usable signal, and the resolution can always be refined once volume justifies it.
Building the graph
Individual points are not corridors. To get from scattered observations to something with shape, consecutive events are joined into edges — but only when the join is plausible.
NODE_GRID_PX = 22.0
MAX_GAP_MINUTES = 35.0
MAX_EDGE_DISTANCE_METERS = 4800.0
MIN_EDGE_DISTANCE_METERS = 12.0
Each constant exists to reject a specific kind of false corridor:
| Constraint | What it rejects |
|---|---|
| gap ≤ 35 min | Two observations far apart in time. The driver went home in between; there is no corridor there. |
| distance ≥ 12 m | Repeated logs from the same spot while stationary, which would otherwise pile up as a fake dense node. |
| distance ≤ 4800 m | Jumps too long to represent continuous driving through observed conditions. |
| 22 px node grid | Snaps nearby points together, so one junction visited fifty times is one node rather than fifty. |
Without these, the graph connects everything to everything and confidently describes corridors that were never driven. The constraints are the difference between a map of observed conditions and an attractive-looking fiction.
Each node and edge carries per-bucket counts, and a dominant bucket is derived from them, so the graph encodes not just where conditions were observed but when.
Intersecting a route
With the graph built, scoring a live offer means constructing the corridor between pickup and dropoff and asking what falls inside it.
The preferred path takes a routed polyline from MapKit, buffers it into a corridor of configurable width, and intersects the beacon geometry against that shape. Where no routed path is available it falls back to a straight line between the endpoints, buffered wider to compensate for the fact that a straight line is a poor model of a road.
The corridor is never a zero-width line. Width absorbs route uncertainty, lane spread, and congestion bleeding outward from the road that causes it. The goal is operational usefulness rather than geometric purity — a mathematically exact line through a city is precisely wrong.
Three kinds of hit
| Hit | Meaning |
|---|---|
| Exact | The beacon lies inside the primary corridor. |
| Near | Just outside, but close enough to suggest route pressure. |
| Timed | A hit whose time profile matches the current trip — same 15-minute window, weekday, or weekpart. |
Timed hits carry more weight than generic historical ones, which is the whole point of bucketing the evidence in the first place. A corridor that jams every weekday evening is irrelevant to a Sunday morning trip, and a model that cannot express that difference will keep declining good work.
The weighting runs in this order:
- exact hits that are also timed — strongest
- exact hits, untimed
- near hits that are timed
- near hits, untimed — weakest
Where the operator overrules the model
One deliberate design decision: explicit operator rules are evaluated separately from beacon evidence, and can override it.
Some corridors are known bad by direct experience long before the statistics agree. The Parkhurst Road and Holloway Road pull is a confirmed trap because the driver has repeatedly lived it. Waiting for the beacon database to reach significance before acting on that would be pedantry — discarding the most reliable evidence available on the grounds that it is not yet numerous.
Keeping operator hits in a separate field also keeps the decision explainable. The output
can say RED x2 and Parkhurst trap as
distinct reasons, rather than collapsing both into an opaque score. A driver who does not
understand why a job was flagged will override the system, and a system that gets
overridden is not deployed.
The output contract
The engine returns compact evidence rather than a bare verdict, so the decision layer can weigh it against fare, pickup cost and rating rather than being dictated to:
struct RouteCorridorEngineOutput {
let routeMode: RouteMode
let corridorWidthMeters: Double
let exactHits: Int
let nearHits: Int
let timedHits: Int
let weightedTrapScore: Double
let operatorRuleHits: Int
let matchedBeaconIDs: [UUID]
let matchedOutcodes: [String]
let matchedSectors: [String]
let primaryReason: String?
let confidence: Double
}
primaryReason and confidence are there for
the same reason as the separate operator field. A score with no explanation and no stated
uncertainty cannot be debugged, cannot be trusted, and cannot be improved.
What is proven, and what is not
Being precise about this matters more than making the project sound finished.
| Component | Status |
|---|---|
| GPS beacon logging from a two-tap Shortcut | Proven in live use |
| Postcode / outcode / sector extraction from noisy OCR | Proven in live use |
| Traffic evidence grouped by area and time bucket | Proven in live use |
| Corridor graph construction and visualisation | Built and running offline |
| Route-line scoring against beacon geometry | Shadow mode — scored but not acted on |
| Native iOS engine, MapKit routed corridors | Specified, not built |
Shadow mode means the corridor score is computed alongside real decisions and recorded, without influencing them. It is the only honest way to evaluate a scoring change: you find out whether it would have been right before you let it cost anyone money.
The native engine is a written contract rather than a shipped app — input and output shapes, route modes, weighting direction and failure behaviour. Writing that down before building it is how the Python implementation and the eventual Swift one stay the same product rather than two divergent guesses.