How it works

From a ride request to a driver at the kerb

hermod decides which driver picks up which rider. This page follows a request through it, step by step: what each part does, how it's built, and how it's checked. For what came out, see the results; for how to read the maps, the guide.

The pieces

OpenStreetMapManhattan's drivable roads, one-way streets included
NYC taxi tripsEvery yellow-cab trip of January 2010, with its pickup point and time
The engineRoad graph, a grid of drivers, claims on drivers, a matcher: greedy or batched
SimulatorA seeded clock replays the trips against a modelled fleet
Live modeThe wall clock, behind an HTTP API, under load
The event logEvery offer, answer, pickup and drop-off, one line each
Read byThe invariant checker, Postgres, and the stream this site shows
Everything hermod decides is written to one event log. Nothing that checks, stores or shows a run reads the engine; it reads the log.

The engine is Go, and its core uses only Go's standard library; a test fails if that ever changes. The same engine runs in two ways: under a simulator with its own clock, which makes runs repeatable, and live, on the wall clock behind an HTTP API, which makes it fast enough or not.

The map

The roads are Manhattan's drivable streets from OpenStreetMap: 36,108 junctions and the one-way streets between them, kept as compact arrays. Each street gets a travel time from a speed for its kind of road, and that time is scaled by the hour of day, fitted so the simulated trips take as long as the taxis' recorded ones did: the median ratio lands between 0.995 and 1.02.

Routes come from Dijkstra's algorithm and A*. To time many drivers to one rider, a single search runs backwards from the pickup and stops at 10 minutes: timing 20 drivers that way takes 0.79 ms. Against OSRM, a production routing engine, on the same map, the median route length differs by 0.04%.

Finding nearby drivers

Drivers sit in a grid of 250 m cells laid over the map, each cell with its own lock, so drivers reporting positions in different places don't wait for each other. A request asks the grid for the 20 nearest idle drivers as the crow flies, then times each of them by road.

Finding the 20 takes 1.69 µs. The same query against Valkey's GEOSEARCH over loopback takes 106 µs, 62 times as long, and the two pick the same drivers, bar near-ties at the twentieth place.

Claiming a driver

Live, many requests are matched at once, and two can want the same driver. Each driver's state is one atomic word, changed only by compare-and-swap, so exactly one claim wins and the other moves on to its next choice.

The word also carries an epoch, bumped by every change. Without it, a matcher that claimed a driver and published a moment later couldn't tell its own claim from a newer one, if the driver had gone offline, come back and been claimed again in between: the classic ABA problem. It turned up while live mode was being built, and the epoch closed it.

Greedy or batched

  • GreedyEach request, the moment it arrives, gets the driver who can reach it soonest among the 20 nearest idle ones. Fast and simple, but it can send a driver far across town to whoever asked first.
  • BatchedRequests wait in a pool for a short window, 10 seconds by default. Then the whole pool is matched at once with the Hungarian algorithm: first as many riders matched as possible, then the least total pickup time.

When drivers are short, batching serves far more riders: with 2,000 drivers on the 2010 rush hour, 43% of riders against greedy's 29%, because each scarce driver goes to a rider close by. With plenty of drivers the two come close. Half the 2010 pickup times are rounded to the minute, which bunches requests and flatters any batching; the results page says how much that matters.

A ride's life

A request is offered to a driver, who accepts or declines. A decline, or no answer within 15 seconds, sends it back to matching. An accepted ride is driven to the pickup, then to the drop-off, and ends completed. A rider can cancel before pickup, and a request no driver takes within 5 minutes expires. Every change is an event in the log.

In the simulator, drivers answer 1 to 10 seconds after an offer, 10% say no, and 5% never answer; 5% of riders cancel in their first 5 minutes.

The simulator

A discrete-event loop on one goroutine: events wait in a priority queue ordered by simulated time, and randomness comes from sources seeded per run. Nothing reads the wall clock, and nothing iterates a map into the log, so the same seed gives a byte-identical event log, every time.

Demand is real: every Manhattan yellow-cab trip from January 2010, at its recorded pickup time and place. 2010 is the last year whose published trip records still carry exact coordinates rather than zones. Supply is a model: there's no public driver data, so N drivers stay online on 8-hour shifts, starting where the previous hour's trips ended. That's why every simulated result is a curve over N, never a single number.

The invariant checker

A separate checker reads nothing but the event log, and replays it against its own state tables, written apart from the engine. Every simulated run, every load test and every demo run goes through it, and a violation fails the run. It checks that:

  • no driver ever holds two rides at once;
  • no offer goes to a driver who is offline;
  • every request ends exactly once: completed, cancelled or expired, never lost and never twice;
  • every state change is one the lifecycle allows.

Under concurrent load, five three-minute runs sent hermod 8.1 million requests. The checker found no violation.

Live mode and the demo

hermod serve runs the same engine on the wall clock. An ingest API takes drivers' positions, shift changes, ride requests and cancels; a separate public listener streams every event as Server-Sent Events. A viewer that falls behind is dropped: the engine never waits for a viewer. At Manhattan's busiest hour, with 10,000 drivers reporting every 4 seconds, a ride request took 4.2 ms at the 99th percentile, batched, on one laptop.

The live page shows a paced demo on the developer's own server: the 2010-01-13 morning, 07:00 to 10:00, with 6,000 drivers batched every 10 seconds, played at ten times its speed and looping over five seeds. The pacing only waits between events, so what streams is the unpaced run's log, byte for byte. Each run is checked when it ends. The stream sits behind a Cloudflare Turnstile check, so it serves people rather than bots.

Storage

Postgres keeps a copy of finished runs, built from their event logs, and is never on the matching path: if it goes down, matching carries on and the run is loaded later. A run's ID comes from its log's hash, so loading the same log twice leaves the same rows. The live page's list of recent runs reads from it.

What's next

The second half of hermod is route sequencing on Amazon's 2021 Last Mile Routing Research Challenge: put a delivery route's stops, about 150 of them, in the order a good driver would, scored with the challenge's official scorer against the drivers' real orders and the leaderboard. The harness and the scoring are built; the sequencer is being written.

The limits

  • The drivers are a modelSupply, answers and shifts are invented, which is why results are curves over the number of drivers.
  • The demand is from 2010With static road speeds scaled by the hour: no traffic, no turn restrictions, and half the pickup times rounded to the minute.
  • One process, one laptopEvery load number comes from one laptop that also ran the load generator.