Route corridor engine · Shadow mode

Judging a route by what it crosses

A spatial-temporal graph built from traffic observations a driver logged themselves, intersected against the corridor between pickup and dropoff, and weighted by how well each hit matches the current time of day.

Traffic corridor map: beacon nodes across central London stitched into directional corridors, filterable by time bucket
The corridor graph, built from real logged observations. 67 beacons resolved into 42 hotspot nodes, 43 corridors and 62 stitched links. Filters across the four time buckets, because a corridor that jams on weekday evenings is irrelevant to a Sunday morning trip. Areas are shown at outcode level.

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:

A trip can look fine on dropoff postcode, destination family and pay — and still be operationally bad, because the route crosses known trap corridors on the way.

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.

BucketHours
night00:00 – 06:00
morning06:00 – 11:00
midday11:00 – 16:00
evening16: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.

build_traffic_beacon_visual_map.py
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:

ConstraintWhat it rejects
gap ≤ 35 minTwo observations far apart in time. The driver went home in between; there is no corridor there.
distance ≥ 12 mRepeated logs from the same spot while stationary, which would otherwise pile up as a fake dense node.
distance ≤ 4800 mJumps too long to represent continuous driving through observed conditions.
22 px node gridSnaps 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.

A route from pickup to dropoff buffered into a corridor, showing exact, near, timed and ignored traffic beacons
How the route becomes evidence. Beacons inside the primary buffer count as exact hits, nearby beacons carry less weight, and observations matching the trip's time profile are promoted. Explicit operator rules remain separate and can override the score.

Three kinds of hit

HitMeaning
ExactThe beacon lies inside the primary corridor.
NearJust outside, but close enough to suggest route pressure.
TimedA 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:

  1. exact hits that are also timed — strongest
  2. exact hits, untimed
  3. near hits that are timed
  4. 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:

IOS_ROUTE_CORRIDOR_ENGINE_CONTRACT.md
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.

ComponentStatus
GPS beacon logging from a two-tap ShortcutProven in live use
Postcode / outcode / sector extraction from noisy OCRProven in live use
Traffic evidence grouped by area and time bucketProven in live use
Corridor graph construction and visualisationBuilt and running offline
Route-line scoring against beacon geometryShadow mode — scored but not acted on
Native iOS engine, MapKit routed corridorsSpecified, 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.

An offer is not judged only by fare. A destination is not judged only by postcode family. The path to the job matters as much as the endpoint.