Drag the rider slider and watch the red dots in the outer ring refuse to go away. That is the whole argument in about fifteen seconds.
A simulation of one dark store on a weekday evening, around 250 grocery orders between 5pm and 10pm, riders on two-wheelers through Faridabad's peak traffic, in the Delhi NCR.
There is no machine learning in this project. It is a clock, a queue, and the ability to run the same evening again with exactly one thing changed.
The deliverable is a decision, not a simulator. The simulation is the evidence. DECISION.md is the output: what to do, what it costs, what we give up, what would change my mind, and the one number we'd have to go measure before half of it is actionable.
The most useful thing that happened while building it: the business model reversed the operational recommendation. The ops analysis said split the store into two. Priced properly, that option destroys ₹45.8L over twelve months and never wins at any volume, churn rate or time horizon. Optimising cost per order and on-time percentage in isolation produced a confidently wrong answer, and adding revenue to the model is what caught it.
It exists to answer a question quick-commerce companies argue about constantly and cannot settle from their own data: when an order is late, what actually caused it? In real life the evening happens once. Ops says hire riders, finance says the batching is too loose, the store manager says the picking counter was swamped, and every one of those stories is consistent with the same 19-minute delivery. A simulation can replay that identical evening with one more rider in it. If the order arrives on time in that version, the rider shortage is what caused it, in the only sense of the word that survives an argument.
Ten minutes is a distance, not a speed.
A ten-minute promise spends roughly 100 seconds picking the order and another 75 standing at the customer's gate waiting for a lift, a guard or a phone to be answered. Whatever is left is riding time, and in peak traffic that gets you about 1.56 km.
A store serving a 2.0 km catchment therefore has customers it cannot reach in time, no matter what it does. Those orders are late before a rider is assigned.
Across 25 simulated evenings and 6,161 orders, 66% of all late deliveries were geometrically unwinnable. The on-time ceiling for that store is 74.9%. Every staffing argument its managers ever have is happening underneath a ceiling nobody in the room has measured.
The instinct is to hire. Here is what hiring buys, over 40 seeded evenings per point:
| riders | on-time | cost/order |
|---|---|---|
| 12 | 41.2% | ₹51.16 |
| 14 | 61.2% | ₹54.67 |
| 16 | 68.2% | ₹58.19 |
| 20 | 71.2% | ₹65.22 |
| 24 | 71.2% | ₹72.25 |
Going from 14 riders to 24 costs ₹17.58 more per order, a 32% increase, and buys 10 points of on-time. Nearly all of that comes in the first few hires. After 20 riders the curve is flat and you are paying people to stand still, because what is holding you back is no longer the queue. It is the map.
Now the lever nobody models. Same customers, same total area covered, same 14 riders. The only difference is whether they are served from one 2.0 km store or two 1.4 km stores:
| 1 store @ 2.0 km | 2 stores @ 1.4 km | |
|---|---|---|
| on-time | 60.5% | 93.4% |
| p50 / p95 | 9.2 / 14.2 min | 7.0 / 10.6 min |
| cost per order | ₹54.66 | ₹62.68 |
| evenings below 95% | 100% | 50% |
| area served | 12.6 km² | 12.3 km² |
+33 points of on-time for ₹8.02 per order. That ₹8 is the second picking counter. Hiring cannot buy the same result at any price.
On operational metrics alone, that settles it: splitting the store beats hiring on both axes. It is also the wrong answer, and finding out why is the most useful thing in this project.
Cost per order is the wrong thing to optimise on its own, because it is lowest when you serve nobody. The operational model prices what each lever costs and says nothing about what each lever gives up. Three things it ignored:
- A smaller radius serves fewer people. Addressable population scales with area, so 1.4 km reaches half of what 2.0 km does. The ops chart shows the service gain; only the business model shows the bill.
- Late deliveries destroy future revenue. Lateness isn't a cost that lands this month, it's a customer you stop having.
- A second store is capital. ₹18L of fit-out plus a doubled fixed base, all of it landing well before any of the benefit does.
Adding those (sim/economics.py, sim/decision.py) inverts the ranking:
| option | on-time | cost/order | 12-month contribution |
|---|---|---|---|
| A · Status quo, 2.0 km, 14 riders | 60.5% | ₹54.66 | ₹72.0L |
| B · Hire, 20 riders | 71.6% | ₹65.20 | ₹51.1L |
| C · Shrink radius to 1.6 km | 89.5% | ₹54.56 | ₹70.8L |
| D · Second store, 2 × 1.4 km | 93.4% | ₹62.68 | ₹26.2L |
The best service is the worst business. Option D keeps 34% more customers, so better service genuinely does reduce churn, and it still destroys ₹45.8L. The fixed costs need far more volume than this catchment produces. I checked whether growth rescues it. At 4× current demand, with riders and pickers scaled on both sides, it is still behind.
Both of the intuitive answers, hiring and opening another store, lose everywhere in the parameter space at every churn rate, density and time horizon out to 36 months. Those two decisions can be closed rather than revisited.
The link from lateness to churn is load-bearing and unmeasured. Rather than picking a plausible value and sounding confident, the output is a breakeven:
| incremental monthly churn per late delivery | best option |
|---|---|
| below 0.033 | do nothing |
| 0.033 and above | cap the radius at 1.6 km |
That threshold is the deliverable. It converts "I think we should shrink the radius" into "shrink it if this number is above 0.033, and here is how to measure it with two weeks of analyst time on order logs we already have."
Full reasoning, risks and instrumentation plan: DECISION.md.
The catchment decides everything, and it is nearly free to change. Holding staffing fixed and varying only the service radius: 1.2 km gives 99.3% on-time, 2.0 km gives 61.2%, 3.0 km gives 7.7%, while cost per order moves from ₹54.49 to ₹55.51. A 92-point swing in service for a rounding error in cost. (This sweep holds order volume constant, which assumes a denser catchment; the one-store-versus-two comparison above is the version where that assumption is removed and total demand is genuinely conserved.)
The cheapest lever is the number in the app, and I can't tell you if it works. At 14 riders and a 2 km radius, a 10-minute promise holds 61.2% of the time and 16 minutes holds 97.8%. Nothing operational changes, so this costs nothing at all. What it is worth depends entirely on why customers leave, and there are two stories:
| advertise 16 min instead of 10 | |
|---|---|
| if customers churn on broken promises | +₹19.3L a year |
| if customers churn on absolute wait time | +₹0.9L a year |
Same orders, same riders, same delivery times. The share of orders taking over 15 minutes barely moves (5.0% to 3.4%), which is why the second column is nearly flat. It stays ahead by ₹17.5L under the first story even if advertising a slower promise costs 30% of new signups.
So this is either the most valuable lever in the analysis or a rounding error, and which one it is turns on a mechanism nobody has measured. I've left it as a question rather than dressing it up as a recommendation. An earlier version of this section just showed the delivery-time distribution and called it a finding, which was close to tautological: promise more time, hit it more often.
The policy you choose for Tuesday is the wrong one for Friday. Under a demand surge, p95 delivery time:
| demand | immediate | slack-aware | hold-and-batch |
|---|---|---|---|
| 1.0× | 14.0 min | 14.0 min | 13.4 min |
| 1.4× | 39.3 min | 39.5 min | 17.3 min |
| 2.0× | 105.1 min | 105.8 min | 32.1 min |
At double demand the batching rule is three times better than the two that matched it on a normal evening. Batching looks like a cost saving but is really a capacity buffer. It only shows its value when the store is drowning, which is exactly when nobody has time to change how dispatch works.
This is the part that is genuinely hard, and the reason the rest is trustworthy.
Two properties make causal attribution possible:
Demand is generated from a seed, before any policy exists. The order stream, who ordered, what, from where, at what minute, with what basket size and pick
time and handover duration, is fixed on the Order objects at generation time.
Changing the rider count cannot change the customers.
The engine draws no randomness at all. Given the same orders and config it produces byte-identical output. Every random quantity was already drawn.
So the evening can be re-run with exactly one variable perturbed. For each late order the simulator checks five single-change worlds, one more picker, two more riders, five more riders, no batching, free-flow traffic, and reports which of them would have saved it, and therefore which lever to actually pull.
Orders whose theoretical floor (pick + ride + handover, with a picker free and
a rider idling at the door) already exceeds the promise are separated out first
and labelled geometry. Telling an operator to hire riders for an order that was
doomed by the service radius is worse than useless.
The cost is six simulations per evening rather than one, because each counterfactual world is built once and every late order is then checked against all of them. Attributing 164 late orders costs six runs, not a thousand.
DECISION.md the deliverable, recommendation, tradeoffs, risks, metrics
sim/prng.py a generator that produces the same stream in both languages
sim/world.py geography, demand generation, physical constants
sim/engine.py the discrete-event loop, one evening in ~11 ms
sim/policies.py four dispatch policies
sim/counterfactual.py the blame engine
sim/metrics.py percentiles, cost, utilisation
sim/experiments.py six Monte Carlo experiments over 40 seeded evenings each
sim/economics.py AOV, margin, churn-from-lateness, addressable demand
sim/decision.py the four options priced as 12-month P&L, plus breakeven
frontend/sim.js the same simulator in JavaScript, so the page needs no server
frontend/app.js map, charts, the late-order deck
frontend/ui.js the portal hero, scroll reveals, the throwable deck
frontend/results/ pre-computed experiment output, read as static JSON
tests/test_core.py 29 test groups
tests/test_conformance.py proves the two simulators agree
The simulator exists twice, in Python and in JavaScript, and that is deliberate rather than accidental. Python does the heavy Monte Carlo work: forty seeded evenings per data point across six experiments. JavaScript runs in the browser so a visitor can drag a slider and re-simulate an evening in about ten milliseconds with no server involved anywhere.
Two implementations of one model is a genuine hazard, so they are pinned
together rather than trusted. Both use the same 32-bit generator, bit-for-bit
identical, and tests/test_conformance.py runs the same seeds through both and
fails if any delivery time disagrees by more than a microsecond. The deploy
workflow runs it, so the two cannot drift apart on the live site.
late 164/261 · unavoidable 43% · ceiling 73.2%
geometry 70 ← no staffing decision reaches these addresses
two_more_riders 45
five_more_riders 25
no_batching 15
no_traffic 9
DECISION.md the deliverable, recommendation, tradeoffs, risks, metrics
sim/prng.py a generator that produces the same stream in both languages
sim/world.py geography, demand generation, physical constants
sim/engine.py the discrete-event loop, one evening in ~11 ms
sim/policies.py four dispatch policies
sim/counterfactual.py the blame engine
sim/metrics.py percentiles, cost, utilisation
sim/experiments.py six Monte Carlo experiments over 40 seeded evenings each
sim/economics.py AOV, margin, churn-from-lateness, addressable demand
sim/decision.py the four options priced as 12-month P&L, plus breakeven
frontend/sim.js the same simulator in JavaScript, so the page needs no server
frontend/app.js map, charts, the late-order deck
frontend/ui.js the portal hero, scroll reveals, the throwable deck
frontend/results/ pre-computed experiment output, read as static JSON
tests/test_core.py 29 test groups
tests/test_conformance.py proves the two simulators agree
The simulator exists twice, in Python and in JavaScript, and that is deliberate rather than accidental. Python does the heavy Monte Carlo work: forty seeded evenings per data point across six experiments. JavaScript runs in the browser so a visitor can drag a slider and re-simulate an evening in about ten milliseconds with no server involved anywhere.
Two implementations of one model is a genuine hazard, so they are pinned
together rather than trusted. Both use the same 32-bit generator, bit-for-bit
identical, and tests/test_conformance.py runs the same seeds through both and
fails if any delivery time disagrees by more than a microsecond. The deploy
workflow runs it, so the two cannot drift apart on the live site.
A worked example of the correction this discipline forces: an early version
computed each order's floor using traffic speed at the minute it was placed.
Peak traffic ends at 21:00, so an order placed at 20:58 was priced at 18 km/h,
then actually rode at 27 km/h and beat its own supposed floor, and got labelled
"unreachable" while being perfectly deliverable. A test that simply asserted
every geometry order stays late when given 60 riders caught it. The floor now
samples the whole window and takes the fastest speed in it, which moved the
unwinnable share from 67% down to 65%. The finding survived; it just got smaller
and true.
The site is static: HTML, CSS, JavaScript and some JSON. GitHub Pages serves it
from frontend/ on every push to main, and the workflow runs both test suites
first, so a change that makes the JavaScript disagree with the Python cannot
reach the live page.
There is no server, no container, no host to pay for and nothing that goes to sleep. That is not a shortcut, it is the reason the simulator was ported to JavaScript in the first place: a link that takes fifty seconds to wake up is a link nobody waits for.
Needs Python 3.9 or newer, and nothing else. No pip install, no virtualenv,
no API keys. requirements.txt is empty on purpose: the simulator is pure
standard library and the site is static files.
DECISION.md the deliverable, recommendation, tradeoffs, risks, metrics
sim/prng.py a generator that produces the same stream in both languages
sim/world.py geography, demand generation, physical constants
sim/engine.py the discrete-event loop, one evening in ~11 ms
sim/policies.py four dispatch policies
sim/counterfactual.py the blame engine
sim/metrics.py percentiles, cost, utilisation
sim/experiments.py six Monte Carlo experiments over 40 seeded evenings each
sim/economics.py AOV, margin, churn-from-lateness, addressable demand
sim/decision.py the four options priced as 12-month P&L, plus breakeven
frontend/sim.js the same simulator in JavaScript, so the page needs no server
frontend/app.js map, charts, the late-order deck
frontend/ui.js the portal hero, scroll reveals, the throwable deck
frontend/results/ pre-computed experiment output, read as static JSON
tests/test_core.py 29 test groups
tests/test_conformance.py proves the two simulators agree
The simulator exists twice, in Python and in JavaScript, and that is deliberate rather than accidental. Python does the heavy Monte Carlo work: forty seeded evenings per data point across six experiments. JavaScript runs in the browser so a visitor can drag a slider and re-simulate an evening in about ten milliseconds with no server involved anywhere.
Two implementations of one model is a genuine hazard, so they are pinned
together rather than trusted. Both use the same 32-bit generator, bit-for-bit
identical, and tests/test_conformance.py runs the same seeds through both and
fails if any delivery time disagrees by more than a microsecond. The deploy
workflow runs it, so the two cannot drift apart on the live site.bash
cd LastKilometre
python -m tests.test_core # simulator correctness python -m tests.test_conformance # python and javascript must agree (needs node) python -m sim.experiments # regenerate the service results python -m sim.decision # regenerate the economics
cd frontend && python -m http.server 8000
DECISION.md the deliverable, recommendation, tradeoffs, risks, metrics
sim/prng.py a generator that produces the same stream in both languages
sim/world.py geography, demand generation, physical constants
sim/engine.py the discrete-event loop, one evening in ~11 ms
sim/policies.py four dispatch policies
sim/counterfactual.py the blame engine
sim/metrics.py percentiles, cost, utilisation
sim/experiments.py six Monte Carlo experiments over 40 seeded evenings each
sim/economics.py AOV, margin, churn-from-lateness, addressable demand
sim/decision.py the four options priced as 12-month P&L, plus breakeven
frontend/sim.js the same simulator in JavaScript, so the page needs no server
frontend/app.js map, charts, the late-order deck
frontend/ui.js the portal hero, scroll reveals, the throwable deck
frontend/results/ pre-computed experiment output, read as static JSON
tests/test_core.py 29 test groups
tests/test_conformance.py proves the two simulators agree
The simulator exists twice, in Python and in JavaScript, and that is deliberate rather than accidental. Python does the heavy Monte Carlo work: forty seeded evenings per data point across six experiments. JavaScript runs in the browser so a visitor can drag a slider and re-simulate an evening in about ten milliseconds with no server involved anywhere.
Two implementations of one model is a genuine hazard, so they are pinned
together rather than trusted. Both use the same 32-bit generator, bit-for-bit
identical, and tests/test_conformance.py runs the same seeds through both and
fails if any delivery time disagrees by more than a microsecond. The deploy
workflow runs it, so the two cannot drift apart on the live site.
Open http://localhost:8000. Drag the rider slider up and watch the red dots in
the outer ring refuse to go away, that is the whole argument in fifteen seconds.
DECISION.md the deliverable, recommendation, tradeoffs, risks, metrics
sim/prng.py a generator that produces the same stream in both languages
sim/world.py geography, demand generation, physical constants
sim/engine.py the discrete-event loop, one evening in ~11 ms
sim/policies.py four dispatch policies
sim/counterfactual.py the blame engine
sim/metrics.py percentiles, cost, utilisation
sim/experiments.py six Monte Carlo experiments over 40 seeded evenings each
sim/economics.py AOV, margin, churn-from-lateness, addressable demand
sim/decision.py the four options priced as 12-month P&L, plus breakeven
frontend/sim.js the same simulator in JavaScript, so the page needs no server
frontend/app.js map, charts, the late-order deck
frontend/ui.js the portal hero, scroll reveals, the throwable deck
frontend/results/ pre-computed experiment output, read as static JSON
tests/test_core.py 29 test groups
tests/test_conformance.py proves the two simulators agree
The simulator exists twice, in Python and in JavaScript, and that is deliberate rather than accidental. Python does the heavy Monte Carlo work: forty seeded evenings per data point across six experiments. JavaScript runs in the browser so a visitor can drag a slider and re-simulate an evening in about ten milliseconds with no server involved anywhere.
Two implementations of one model is a genuine hazard, so they are pinned
together rather than trusted. Both use the same 32-bit generator, bit-for-bit
identical, and tests/test_conformance.py runs the same seeds through both and
fails if any delivery time disagrees by more than a microsecond. The deploy
workflow runs it, so the two cannot drift apart on the live site.
DECISION.md the deliverable, recommendation, tradeoffs, risks, metrics
sim/world.py geography, demand generation, physical constants sim/engine.py the discrete-event loop, one evening in ~11 ms sim/policies.py four dispatch policies sim/counterfactual.py the blame engine sim/metrics.py percentiles, cost, utilisation sim/experiments.py six Monte Carlo experiments over 40 seeded evenings each sim/economics.py AOV, margin, churn-from-lateness, addressable demand sim/decision.py the four real options priced as 12-month P&L, plus breakeven backend/main.py FastAPI, serves results, runs live simulations frontend/ animated store map, charts, live blame panel tests/test_core.py 29 test groups
DECISION.md the deliverable, recommendation, tradeoffs, risks, metrics
sim/prng.py a generator that produces the same stream in both languages
sim/world.py geography, demand generation, physical constants
sim/engine.py the discrete-event loop, one evening in ~11 ms
sim/policies.py four dispatch policies
sim/counterfactual.py the blame engine
sim/metrics.py percentiles, cost, utilisation
sim/experiments.py six Monte Carlo experiments over 40 seeded evenings each
sim/economics.py AOV, margin, churn-from-lateness, addressable demand
sim/decision.py the four options priced as 12-month P&L, plus breakeven
frontend/sim.js the same simulator in JavaScript, so the page needs no server
frontend/app.js map, charts, the late-order deck
frontend/ui.js the portal hero, scroll reveals, the throwable deck
frontend/results/ pre-computed experiment output, read as static JSON
tests/test_core.py 29 test groups
tests/test_conformance.py proves the two simulators agree
The simulator exists twice, in Python and in JavaScript, and that is deliberate rather than accidental. Python does the heavy Monte Carlo work: forty seeded evenings per data point across six experiments. JavaScript runs in the browser so a visitor can drag a slider and re-simulate an evening in about ten milliseconds with no server involved anywhere.
Two implementations of one model is a genuine hazard, so they are pinned
together rather than trusted. Both use the same 32-bit generator, bit-for-bit
identical, and tests/test_conformance.py runs the same seeds through both and
fails if any delivery time disagrees by more than a microsecond. The deploy
workflow runs it, so the two cannot drift apart on the live site.
The clock does not tick. It jumps from one event to the next, which is what makes one evening cost eleven milliseconds and therefore what makes thousands of evenings, and an interactive front end, possible at all. A fixed-timestep loop fine enough to catch a 30-second dispatch decision would be roughly four orders of magnitude slower for identical results.
The mean delivery time is close to useless and this project mostly refuses to report it. A store can move its mean by twenty seconds while its p95 moves four minutes, and only one of those is something a customer experiences. Nobody notices the evening their order took the average time. They notice the evening it took nineteen minutes on a ten-minute promise, and that is the evening they try a competitor. Every comparison here is settled on p95 and on the share of evenings that missed a 95% on-time bar.
Here are the assumptions I'd argue with first if someone handed me this.
- The constants are researched estimates, not real operator data. Rider speed, handover time, pay rates. Real numbers would move the exact figures. They would not move the shape of the finding, which comes out of arithmetic: pick time plus handover plus riding cannot exceed the promise.
- Distance is straight-line × a detour factor: not a road network. A real road graph would make individual trips more accurate and would not change the conclusion, because the promise radius shrinks under a real network rather than growing.
- One store, one shift. No multi-store rebalancing, no rider shift patterns, no inventory stockouts, no cancellations, no returning customers.
- Demand does not respond to service. In reality, a store that misses its promise loses the customer, which is the feedback loop that makes the finding matter commercially, and it is not modelled here.
Nearly every AI portfolio project proves the same thing: that the author can call a model's API. That was worth proving in 2023 and is table stakes now.
This is an attempt at something harder, modelling how a business physically works, running experiments against it, and coming back with a recommendation someone could act on tomorrow. What makes it a product exercise rather than an engineering one is that the analysis argued with itself: the operational model gave a confident answer, and pricing what that answer gave up reversed it. The version of me that stopped at "cost per order and on-time percentage" would have recommended spending ₹18 lakh on a second store that destroys ₹45.8 lakh of value.
"I simulated a quick-commerce dark store to settle a question operating data can't: when a delivery is late, what caused it. The simulator is deterministic and demand is seeded independently of policy, so I can replay the identical evening with one variable changed and get genuine causal attribution per order.
The operational finding is that a ten-minute promise is a distance, not a speed, after picking and handover you have about seven minutes of riding, which is 1.5 km in traffic. A store serving 2 km has an on-time ceiling of 75% that no amount of hiring can touch, because 66% of its late orders are geometrically unwinnable.
The part I'd actually talk about is that the operational answer was wrong. Optimising on-time and cost per order said split the store in two. Adding revenue, churn and capital said that option destroys ₹45.8 lakh and never wins at any volume or churn rate. The right call is to cap the service radius, > nearly free, thirty points of service, and it hinges entirely on one number nobody has measured, so I shipped the breakeven instead of a fake point estimate: above 0.033 incremental churn per late delivery, do it; below, don't; here's how to measure it in two weeks from order logs we already have."