It's 5pm on a weekday, and you're standing outside a stadium after an event just wrapped up. You open your ride-share app. Estimated wait: 12 minutes. Meanwhile, three neighborhoods over, drivers are circling empty streets with nobody requesting a ride.
This happens constantly on any ride-share platform. It's not a bug — it's just supply and demand failing to line up on their own.
Someone, or something, has to watch each part of the city. It needs to notice when a zone is out of balance, then decide what to do:
- Raise prices, to pull in more drivers
- Pay drivers a bonus to reposition
- Redirect riders to a calmer zone
- Or just leave it alone, if it'll sort itself out
That decision-maker is what I'm building in this series, using LangGraph — one capability at a time.
This is Part 1 of a 5-part series. Each part adds exactly one thing the previous one genuinely couldn't do, so the agent gets closer to how this would actually have to work in production:
- Part 1 (this one): a rule-based agent for a single zone — no LLM, everything is structured numbers
- Part 2: an LLM, but only for the two jobs a lookup table can't do — reading a human's free-text note, and explaining the decision back
- Part 3: memory, so the agent remembers what it already tried on a zone instead of deciding once and forgetting
- Part 4: a human-in-the-loop pause, so a risky or unusual decision doesn't just execute unattended
- Part 5: coordinating two zones at once, since pulling a driver from one zone is a decision that affects both
Nothing above is built yet in Part 1. That's deliberate. Every one of those additions is real complexity, and real complexity should only show up once the problem actually needs it — not because it sounds impressive.
So Part 1 stays as plain as the problem allows: one zone, one decision cycle, and rules a human ops person could write on a whiteboard.
The Data: A Snapshot of a City
Before there's an agent, there's a city to look at. I generate a synthetic snapshot of 8 zones — downtown, an airport, a couple of suburbs, a university district, and so on.
Each zone has:
- A driver count
- A rider request count
- An average wait time
- A couple of context flags — is it raining, is there an event nearby
zones = generate_all_zones(hour=17, rain=False, event=True, seed=42)
df = pd.DataFrame(zones)[[
'zone_name', 'zone_type', 'driver_count',
'rider_request_count', 'avg_wait_time', 'rain_flag', 'event_flag', 'traffic'
]]
df['ratio'] = (df['rider_request_count'] / df['driver_count']).round(2)
df
| zone_name | driver_count | rider_request_count | avg_wait_time | ratio |
|---|---|---|---|---|
| Downtown Core | 15 | 29 | 6.3 | 1.93 |
| Airport | 8 | 17 | 6.7 | 2.12 |
| Midtown | 6 | 14 | 8.9 | 2.33 |
| Stadium Area | 6 | 15 | 9.2 | 2.50 |
The ratio column is rider requests divided by drivers. It's the whole signal the agent needs:
- Above 1.3 → deficit (too few drivers)
- Below 0.5 → surplus (too many idle drivers)
- In between → balanced
Downtown Core is at 1.93 — a real deficit, made worse by a nearby event. That's the zone I'll walk through.
The Graph
Here's the shape of the decision, end to end:
detect_imbalance
↓
classify_severity
↓ (conditional edge)
┌────┴────────────┬──────────────┐
↓ ↓ ↓
demand_action supply_action do_nothing
└────────┬────────┴──────────────┘
↓
resolved_imbalance
↓
choose_best_policy
↓
simulate_and_report
Two nodes run first:
-
detect_imbalancecomputes the ratio. -
classify_severitylabels it mild, moderate, or critical. That label is for reporting only — it doesn't affect the decision.
Then comes the conditional edge, where the actual branching happens. It reads imbalance_type and decides which policies are even worth trying:
-
deficit →
surge_pricing,driver_bonus,demand_redirect, ordo_nothing -
surplus →
reallocation_nudgeordo_nothing(a zone with too many idle drivers doesn't need demand-side incentives) -
balanced →
do_nothing
Two more nodes run after that:
-
resolved_imbalanceevaluates every candidate policy once. It records each one's profit, and whether it actually fixes the imbalance. -
choose_best_policypicks the most profitable policy among those that resolve it. If none of them resolve it, it falls back to picking the most profitable one overall.
One more thing worth naming: every policy here is zone-local. None of them look past the selected zone's own numbers.
Pulling a driver in from a neighboring zone is a real idea. But it needs a second zone in the picture first — that's Part 5, not Part 1.
The State
LangGraph's StateGraph runs on a single shared piece of state. Every node reads from it and writes to it. In Python, that's just a TypedDict:
class ZoneState(TypedDict):
zone: dict # the single zone this graph run studies
imbalance_ratio: float
imbalance_type: str # "deficit" | "surplus" | "balanced"
severity: str # "none" | "mild" | "moderate" | "critical"
candidate_policies: list # which policies are worth evaluating, set by the routed branch
policy_evaluations: dict # {policy_name: profit} for every candidate considered
policy_resolutions: dict # {policy_name: bool} — did this candidate resolve the imbalance
recommended_policy: str
outcome: dict
outcome_delta: dict
report: str
Nothing here is LangGraph-specific magic. It's a plain dictionary shape. The framework's only job is making sure every node agrees on it.
Wiring the Graph
Each node is a plain Python function. It takes the current state in, and returns whichever fields it changed. Nodes don't call each other directly — the graph's edges decide what runs next.
Wiring them together takes three steps:
- Register each node under a name.
- Connect the straightforward ones with
add_edge. - Use
add_conditional_edgesfor the one spot where the next step depends on the state.
g = StateGraph(ZoneState)
g.add_node("detect_imbalance", detect_imbalance)
g.add_node("classify_severity", classify_severity)
g.add_node("demand_action", demand_action)
g.add_node("supply_action", supply_action)
g.add_node("do_nothing", do_nothing)
g.add_node("resolved_imbalance", resolved_imbalance)
g.add_node("choose_best_policy", choose_best_policy)
g.add_node("simulate_and_report", simulate_and_report)
g.add_edge(START, "detect_imbalance")
g.add_edge("detect_imbalance", "classify_severity")
g.add_conditional_edges("classify_severity", route_action, {
"demand_action": "demand_action",
"supply_action": "supply_action",
"do_nothing": "do_nothing",
})
g.add_edge("demand_action", "resolved_imbalance")
g.add_edge("supply_action", "resolved_imbalance")
g.add_edge("do_nothing", "resolved_imbalance")
g.add_edge("resolved_imbalance", "choose_best_policy")
g.add_edge("choose_best_policy", "simulate_and_report")
g.add_edge("simulate_and_report", END)
app = g.compile()
route_action is the function behind that conditional edge. It reads imbalance_type off the state, and returns the name of the branch to take.
That's the entire routing logic for this stage — one if-shaped decision, expressed as data instead of a chain of if statements.
Running It on Downtown Core
With the graph compiled, running it takes one call: invoke(), with Downtown Core's snapshot as the starting state.
result = app.invoke(initial_state)
Here's what came back:
| Policy | Profit | Resolved? |
|---|---|---|
| surge_pricing ← chosen | $230.78 | No |
| demand_redirect | $202.46 | No |
| driver_bonus | $181.83 | No |
| do_nothing | $177.84 | No |
surge_pricing won on profit — same as it would have without the resolution check. But notice that nothing resolved the imbalance in one pass.
That's not a bug. resolved_imbalance only measures a policy's effect over one 15-minute window. A deficit this size — 29 riders against 15 drivers — genuinely needs more than one nudge to close.
The agent still made the best call it could. It just can't promise a full fix in a single shot, and it says so honestly (resolved=NO) instead of pretending otherwise. Giving it the ability to try again, and remember what it already attempted, is exactly what Part 3 adds.
Fast-Forwarding the Outcome
The last node is simulate_and_report. It plays the chosen policy forward 15 minutes.
The simulation is stochastic. Drivers respond to incentives probabilistically, the way real people do — not on a fixed schedule.
| Metric | Before | After | Change |
|---|---|---|---|
| driver_count | 15 | 16 | +1 |
| rider_request_count | 29 | 25 | −4 |
| avg_wait_time (min) | 6.3 | 5.5 | −0.8 |
Directionally correct: more drivers, fewer pending requests, a shorter wait. Just not enough to flip Downtown Core out of deficit territory in one cycle.
What's Missing
Everything the agent looked at here was already structured — numbers and boolean flags. That's exactly why plain rules could handle it. There was no ambiguity for a language model to resolve.
But an ops person doesn't write structured fields. They write something like "the stadium event just got cancelled, and there's an accident backing up traffic near the venue." Stage 1's agent has no way to read that sentence. No lookup table fixes that — it needs an LLM, used for exactly one narrow job. That's where Part 2 picks up.
Code for this series: github.com/ebiarian/zone-balancing-ridesharing-langgraph-agent
Next — Part 2: teaching the agent to read a human's ops note, and to explain its own decision back in plain English — plus a head-to-head comparison of five local LLMs to see which one actually holds up at the job.
Top comments (0)