DEV Community

Cover image for I shut down the L train in a FalkorDB graph: 23,445 riders, one min cut, and a fix tested on a fork
SAUD SATOPAY
SAUD SATOPAY

Posted on AI-assisted

I shut down the L train in a FalkorDB graph: 23,445 riders, one min cut, and a fix tested on a fork

Picture 8 a.m. on a Wednesday in October. A signal fire in the Canarsie Tube stops the L between Bedford Av and 8 Av, in both directions, until further notice.

That one sentence breaks the trips of 23,445 riders an hour. A transit control room now has minutes to answer three questions: who is stuck, where will they go, and can the rest of the network carry them?

A trip planner finds one rider a route; this is a flow problem. So for the WeMakeDevs × FalkorDB Graph Hacks hackathon I built Detour: agents on a FalkorDB graph of the real subway that measure what the network can still carry (17,167 of 18,432 riders an hour toward Manhattan, with the bottleneck at 1 Av) and test a shuttle on a forked copy of the graph before anyone is told. The graph computes every number; an independent NetworkX program re-checks them.

One rule shaped everything: the graph computes, and the model only chooses tools and explains. No number in Detour comes from a language model.

TL;DR

  • The real subway in FalkorDB, built from MTA open data: 445 station complexes, 1,459 scheduled segments with capacity, and 30,205 origin-destination flows.
  • When a line breaks, agents call typed graph tools. A MATCH finds who is hit, algo.SPpaths shows where they go, algo.maxFlow measures what the network can still carry and its min cut names the bottleneck, and GRAPH.COPY forks test shuttle plans.
  • Memory, conversations and the agency's own records (people, tickets, chats, decisions) live in the same graph, with dates, and they change the next answer.
  • An independent NetworkX program recomputes every number.

Live demo: https://detour-orpin.vercel.app · Code: https://github.com/SaudSatopay/detour

A run of the L closure in Detour's control room: the controller's tool calls stream into the trace, the night map shows each step's evidence, and the G overload and max flow appear with their numbers.

The L closure in six questions

Question How the graph answers For the L
Whose trips cross the closure? One MATCH on demand edges 23,445 riders an hour
Where do they go on their own? algo.SPpaths, every pair in one query The G at 244%; 4,689 riders with no rail path
What can the network still carry? algo.maxFlow on a fork 17,167 of 18,432 toward Manhattan (93%)
Where is the bottleneck? The min cut 1 Av
Does a shuttle help? The same tools on a GRAPH.COPY fork Riders with no path: 4,689 → 623
Did memory change the plan? Past incidents stored in the graph 27% more buses

Putting the subway in a graph

Everything comes from MTA open data:

  • GTFS static. Weekday trains departing between 8:00 and 8:59 give trains per hour on every directed segment. Multiplied by planning loads per car, that is capacity.
  • Stations and complexes. 445 complexes, with accessibility flags.
  • The 2025 origin-destination ridership estimate. A Wednesday in October at 8 a.m.: 446,230 riders an hour. I keep the 30,205 station pairs with at least two riders, which carry 90.5% of them.

A build script assigns every pair to its fastest route (in-vehicle time, half the headway as the wait, a transfer penalty), which gives every segment a baseline load. Only one segment comes out over capacity at baseline: the 5 from Grand Central to Union Square, at 101%. That was my sign the model was sane.

The model is small on purpose:

// scheduled segments
(:Station)-[:RUNS {route, capacity, load, minutes}]->(:Station)
// complexes under 400 m apart
(:Station)-[:WALK {meters, minutes, capacity}]->(:Station)
// 8 a.m. flows; via = the segment ids of the path
(:Station)-[:DEMAND {riders, via}]->(:Station)
// a platform layer, so waits and transfers cost minutes
(:Station)-[:BOARD]->(:Platform)-[:RIDE]->(:Platform)
(:Platform)-[:ALIGHT]->(:Station)
Enter fullscreen mode Exit fullscreen mode

And each job gets its own graph:

detour:template       the network and seeded memory, never changed
detour:sbx:<visitor>  your own copy, made with GRAPH.COPY
detour:fork:<...>     a throwaway copy for each what-if
detour:demand         the 30,205 flows, shared and read-only
Enter fullscreen mode Exit fullscreen mode

Step 1: Who is hit? One MATCH

Each DEMAND edge keeps the segment ids of its 8 a.m. path in a via property. That one property turns the first question into a single query:

MATCH (o:Station)-[d:DEMAND]->(x:Station)
WHERE any(e IN d.via WHERE e IN $closed)
RETURN o.id AS o, x.id AS d, d.riders AS riders
Enter fullscreen mode Exit fullscreen mode

For the L between Bedford Av and 8 Av, that is 23,445 riders an hour across 2,021 station pairs. Only 7,823 of them board at the closed stations; counting outward, 14,713 board within four stops of the closure. An alert sent only to the closed stations would miss two thirds of the people affected.

Step 2: Where do they go? Shortest paths that price a transfer

Riders don't spread out politely. They take their fastest open path. To make waits and transfers cost something, the graph has a platform layer: a Platform node per station and route, BOARD edges that cost a three-minute transfer penalty plus half the headway, RIDE edges along the scheduled segments, and free ALIGHT edges. Then every displaced pair is rerouted in one query:

UNWIND $pairs AS p
MATCH (a:Station {id: p.o}), (b:Station {id: p.d})
CALL algo.SPpaths({sourceNode: a, targetNode: b,
  relTypes: ['BOARD', 'RIDE', 'ALIGHT', 'WALK'], weightProp: 'minutes'})
YIELD path, pathWeight
RETURN p.o AS o, p.d AS d, pathWeight AS minutes
Enter fullscreen mode Exit fullscreen mode

All 2,021 pairs reroute in under a second. Left to themselves, L riders pile onto the G: four G segments run at 190 to 244% of capacity, and 4,689 riders an hour have no open rail path at all.

Detour's home page: the G runs at 244% of capacity, and the night map shows displaced riders' fastest paths overloading the G in red.

Step 3: What can the network still carry? Max flow and the min cut

Fastest paths show what riders will do. Max flow shows what the network could do if they spread out perfectly.

Each call forks the visitor's graph with GRAPH.COPY, deletes the closed segments, frees the capacity the displaced riders were using, and wires a super source and a super sink to every station by its balance of displaced riders, once per direction of travel. Then:

MATCH (src:Hub {id: $src}), (snk:Hub {id: $snk})
CALL algo.maxFlow({sourceNodes: [src], targetNodes: [snk],
  relationshipTypes: ['FLOW'], capacityProperty: 'spare'})
YIELD maxFlow, edges, edgeFlows
RETURN maxFlow, edges, edgeFlows
Enter fullscreen mode Exit fullscreen mode

algo.maxFlow takes a single relationship type and no parallel edges, and a subway corridor is full of parallel edges: the 4, 5 and 6 run between the same stations, plus walks. So the fork sums the spare capacity per station pair into one FLOW edge and keeps the ids of the segments behind it. That maps the flow and the cut back to real lines.

The cut comes from the residual graph: starting at the super source, the tool follows every edge that still has spare capacity, or flow it could push back, and the cut is every edge from a station it reached to one it didn't.

The answer for the L: 17,167 of the 18,432 riders an hour headed into Manhattan can still move. That's 93%. The min cut sits at 1 Av. Its only rail links are the closed L, so no spare capacity anywhere else can reach its riders. That is exactly where a shuttle bus should start.

Step 4: Test the fix on a fork before anyone is told

GRAPH.COPY makes a what-if cheap. The controller looks for shuttle bridges within 2 km of the stations the network can't clear. Then it copies the closure fork once per plan, adds the bus legs and runs the same tools again:

  • 1 Av ↔ Astor Pl at 25 buses an hour: riders with no path drop from 4,689 to 623, and the max-flow shortfall from 1,266 to 315.
  • A two-leg plan does no better, with 18 more buses. One leg wins.

(Riders an hour are rounded to whole riders, which is why 18,432 minus 17,167 shows up as a 1,266 shortfall.)

One result surprised me. With the shuttle, fastest-path overflow rises, from 24,078 to 28,762, because 4,066 riders who had no way through can now travel. So the plan also needs the rider desk to steer people onto lines with room. And for the other two closures in the demo, the Lexington express and the Cranberry Street Tunnel, the open network absorbs everyone, so the controller proposes steering riders and no buses at all.

Agents that never write Cypher

The model's job is to choose tools and explain the results, not to invent queries or numbers. The agents pick from a fixed catalogue of 13 typed tools, backed by 83 queries. Each tool returns one of four answer shapes (answered, from memory, not in the graph, unknown name), a one-line summary, the data, and the exact Cypher it ran with its timings. The control room shows all of it, step by step.

Here is the recorded L run, with the time each tool spent in FalkorDB:

tool                queries     time  result
resolve_places            2    10 ms  Bedford Av, 14 St/8 Av, the L
recall                    3    10 ms  elevator outage; June shuttle at 79%
close_line                6     9 ms  10 L segments closed
affected_riders          10   152 ms  23,445 riders an hour, 2,021 pairs
measure_capacity         19   811 ms  4 segments overflow, G at 244%
shuttle_candidates       16   220 ms  1 Av -> Astor Pl, 0.8 km
simulate_plan            24  1551 ms  no-path riders 4,689 -> 623
simulate_plan            24  1404 ms  two legs, 26 buses: no better
remember                  4     3 ms  "the G overloads first"
Enter fullscreen mode Exit fullscreen mode

Nine tool calls, 108 queries, 4.2 seconds inside FalkorDB.

dispatcher
    |
    v
controller agent --- 13 typed tools, 83 queries ---> FalkorDB
    |
    v
a person approves
    |  HANDED_OFF
    v
rider desk: one agent per remembered rider
Enter fullscreen mode Exit fullscreen mode

Nothing reaches riders until a person approves a plan. Then the controller hands off to the rider desk, a HANDED_OFF edge records it, and one rider agent per remembered rider writes a new route, step-free where that rider needs it.

After approval: two shuttle plans tested on forked graphs, the recommended one published to riders, and a rider agent routing Tomás around the closure.

Detour runs on free model tiers, and Gemini's free tier allows only a few full runs a day. So I also wrote the controller's procedure as code. When the quota runs out, the same tools run in the same order on the visitor's graph, and the run is labeled scripted. When the database is unreachable, a run recorded on FalkorDB plays back, labeled as a recording. The same tools are also served over MCP, so other agents can use them.

Memory that changes the answer

Memory is not a log beside the graph. It hangs off the same nodes the agents already query:

(:Agent)-[:WROTE]->(:Finding {text, validFromMs, validToMs})
(:Finding)-[:ABOUT]->(:Station | :Route)
(:Finding)-[:SUPERSEDES]->(:Finding)
Enter fullscreen mode Exit fullscreen mode

Here are two moments where it changes what the agents do:

  • A plan sized by experience. A past incident remembers that the June bus bridge carried 79% of its planned riders, so the controller sizes the new shuttle 27% larger.
  • A route that follows the facts. Aisha uses a wheelchair. Memory says the elevator to the L platform at Myrtle-Wyckoff Avs is out, so her step-free route takes the M: 33 minutes. Report it fixed and the old fact gets an end date (it is not deleted), and a new fact supersedes it. Her next route takes the L: 24 minutes.

Aisha's step-free route: the L in 24 minutes now, and the M in 33 minutes before the memory changed.

Because closed facts stay in the graph, the memory page can draw every fact over its validity window, next to incidents and agent steps.

Memory over time: each fact as a bar over its validity window, grouped by line, with past incidents and a closed elevator fact.

Conversations are memory too. Every question to the rider desk or the brain becomes a Turn node, chained by NEXT, linked to the stations it mentions and to the nodes its answer cited. The agents read their history back from the graph rather than from the browser, so a visitor who comes back the next day picks up the same conversation.

A company brain for the agency

The agency that runs the response lives in the same graph. Its teams, people, documents (after-action reports, playbooks, procedures, notices), maintenance tickets, chat messages and decisions are nodes linked to the stations, routes, incidents and findings the agents already use. They are sample records; the subway is real. The brain page answers the questions a new dispatcher would ask:

  • Who owns the Myrtle-Wyckoff elevator outage? OWNS and MAINTAINS edges give the teams and their people, the open ticket gives the assignee, and authorship ranks who knows it best.
  • Why do L tube bus bridges get 27% more buses? The decision is traced back two hops: to the earlier decision, chat message and finding it rests on, and from that finding to the after-action report and ticket it came from.
  • What changed this week? Every dated change in the graph, from facts learned to tickets resolved and decisions lifted.
MATCH (d:Decision {id: $id})-[:BASED_ON]->(f:Finding)-[:SOURCED_FROM]->(s)
RETURN f.id AS finding, collect(DISTINCT s.id) AS sources
Enter fullscreen mode Exit fullscreen mode

Every answer cites the nodes it came from, and it stays current: report Aisha's elevator fixed, and in the same request the outage fact is closed, its ticket resolves, and the decision to route step-free riders around that platform is lifted.

It takes knowledge in, too. Paste Elevator to the A platform at Jay St-MetroTech is stuck between floors into Add to the brain. The message is linked to the station and the A, ticket EE-1208 is filed with the elevator maintenance lead, and the outage goes into memory, so step-free routes avoid that platform at once. A second report joins the same ticket instead of opening another, and back in service resolves it.

Don't trust the graph: check it

I didn't want to trust a number just because a database produced it. A separate Python program built on NetworkX reads the same data files, follows the same written spec, and recomputes everything. FalkorDB and NetworkX agree on the max flow in all six direction groups of the three closures, and on the displaced-rider counts at every depth, and the checker confirms that each min cut's capacity matches its flow. The proof page shows them side by side.

Every visitor also gets their own sandbox graph, so I load-tested the multi-tenancy. 48 reader agents and 6 writers across 6 tenant graphs sustained 7,195 reads and 719 writes a second, with zero errors, and a closure in one tenant never showed up in another.

The proof page: FalkorDB and NetworkX side by side for every closure.

Why FalkorDB, and not just NetworkX?

NetworkX is the auditor: it recomputes everything in a batch job, after the fact. FalkorDB is where the agents work, live and per visitor:

  • Every visitor gets a graph. A sandbox is a GRAPH.COPY of the template, and a what-if is a copy of that, so plans are tested side by side with no locking and no visitor ever sees another's closure.
  • The algorithms run where the data lives. 2,021 displaced pairs reroute in one UNWIND query, max flow runs inside the request, and the whole L run takes 4.2 seconds of database time.
  • One graph holds everything. Stations, demand, memory, conversations and the agency's records are linked, so one query can go from a ticket to a station to a rider's route.
  • Every answer can be audited. Each tool returns the Cypher it ran with its timing, and the control room shows it.

Honest limits

  • Demand is the MTA's estimate for an October Wednesday at 8 a.m., not live counts, and capacity uses planning loads per car.
  • Max flow is the best case, with riders spread perfectly; fastest paths are the worst case. Reality is somewhere between.
  • NetworkX checks the implementation against the same written spec, not the model against reality.
  • The agency's people, tickets, chats and decisions are sample records.
  • Free model tiers run out. Then the standard procedure answers, and with the database down a recorded run plays; both are labeled.
  • The intake reads text with rules (station names, lines, outage words), not a language model, so a station written in an unusual way can be missed.

What broke, and what I learned

  1. Choose a problem where the algorithm is the product. "Can the network absorb the L's riders?" is literally a max flow, and "why not?" is a min cut. A language model can't fake either one, and that kept the agents honest.
  2. Put the expensive question in a property. Storing each flow's path on its DEMAND edge turned "who is affected?" into one MATCH.
  3. Variable-length paths timed out. -[:RUNS*0..8]-> exploded on corridors with many parallel routes. Walking a frontier one hop per query takes milliseconds.
  4. algo.SSpaths was not what I thought. It returns k paths in total, not one per target; one SPpaths call per pair under UNWIND was what I needed. And algo.maxFlow wants one relationship type and no parallel edges, so I build FLOW edges and keep their provenance.
  5. Forks make what-ifs and tenancy simple. GRAPH.COPY gave me a sandbox per visitor and a throwaway graph per plan, with no locking logic.
  6. Close facts; don't delete them. Validity windows plus SUPERSEDES let the next answer read only what is true now, while keeping the history.
  7. SET came back NOPERM. On FalkorDB Cloud's free tier my database user could run graph commands but not plain Redis ones, so rate limits, the seed lock and the sandbox registry moved into a small detour:meta graph, with MERGE doing the locking. Free model quotas run out too, so the controller's procedure also exists as code, and every run says which mode it used.
  8. An approval landed in the wrong sandbox. An end-to-end test on a phone viewport caught a race: a new visitor's first page load could mint two visitor ids. A Next.js proxy now sets the id before any route runs.

What I'd love to see in FalkorDB

  • algo.maxFlow with several relationship types, parallel edges and the min-cut edges in its output.
  • A bounded breadth-first procedure that returns hop distances.
  • Copy-on-write forks, so a fork per what-if would be nearly free.

Try it

  1. Break the L in the control room, open any step to see its evidence and Cypher, and approve the plan.
  2. On the brain page, ask Why do L tube bus bridges get 27% more buses?, then paste an elevator outage into Add to the brain.
  3. Check every number in this post on the proof page, next to NetworkX's.

The code, the graph model and every query are at https://github.com/SaudSatopay/detour. For Aisha's route change, report her elevator fixed on the riders page in a fresh sandbox (Reset my sandbox in the control room): with the L closed, her route stays on the M whatever memory says.

Stack and AI disclosure

Next.js 16, FalkorDB 4.22 on FalkorDB Cloud, the Vercel AI SDK with free Gemini and Groq models, and MTA open data. I built Detour with Claude Code as a pair programmer, and it helped me draft this post. The graph model, the queries and every decision are documented in the repo.

If you have ever had to reroute a city, or broken a network on purpose: what would you fork and test first? And if Detour was useful to you, a star on GitHub helps.

Top comments (1)

Collapse
 
maulik176 profile image
Maulik Ranadive •

"The graph computes, and the model only chooses tools and explains" is a rule more agent projects should follow. Re-checking every number with a separate NetworkX program, and testing the shuttle on a GRAPH.COPY fork before anyone is told, are both very clever. How long does the fork take on the full subway graph?