DEV Community

Cover image for What happens when a kidney swap breaks? Building an exchange agent on FalkorDB
Dhanalakshmi.d.gowda23
Dhanalakshmi.d.gowda23

Posted on

What happens when a kidney swap breaks? Building an exchange agent on FalkorDB

More than 90,000 people in the US are waiting for a kidney. Some of them already have a donor: a partner, a sibling, a friend who would give a kidney tomorrow. The problem is that the donor doesn't match. Wrong blood type, or the patient's immune system would reject that particular kidney.

Kidney paired exchange fixes this by letting pairs swap. My donor gives to you, your donor gives to someone else, and their donor gives back to me. Everyone in the ring gets a kidney that fits.

Three donor-patient pairs swapping donors in a ring
Three pairs in a ring: each donor gives to the next patient.

For Graph Hacks 2026 (WeMakeDevs × FalkorDB), we built Swap, an agent that runs one of these exchanges on a FalkorDB graph. This post is about what we built, the mistake we made halfway through, and what we learned about FalkorDB along the way.

Everything in Swap uses synthetic patients. It's a demo of a workflow, not a clinical tool.

The easy part: finding rings

A kidney exchange is a graph almost by definition. Each pair is a node. If pair A's donor can give to pair B's patient, there's an edge A → B. A swap is a cycle.

Finding every 3-way swap is one Cypher pattern:

MATCH (a:Pair)-[:CAN_GIVE]->(b:Pair)-[:CAN_GIVE]->(c:Pair)-[:CAN_GIVE]->(a)
WHERE a.id < b.id AND a.id < c.id
RETURN a.id, b.id, c.id
Enter fullscreen mode Exit fullscreen mode

The a.id < b.id AND a.id < c.id part makes sure each ring is counted once instead of three times.

On a pool of 200 synthetic pairs, allowing only 2-way swaps matches 88 patients. Allowing 3-way rings matches 106. That's 18 more people from the same donors. This isn't our discovery: Roth, Sönmez and Ünver showed it in 2007, and we reproduce their result. Our generator follows their blood-type and immune-sensitivity mix, and make verify SEEDS=20 recomputes our numbers over 20 random pools.

88 patients matched with 2-way swaps, 106 with up to 3-way
Same 200 synthetic pairs: 88 matched with 2-way swaps only, 106 once 3-way rings are allowed.

The hard part: plans fall apart

Here's what got us interested. Research on US exchanges (Dickerson, Procaccia and Sandholm, Failure-Aware Kidney Exchange) found that most planned exchange transplants never actually happen. A common reason is the crossmatch, a lab test run shortly before surgery. If it comes back positive, that donor can't give to that patient, and the whole ring they were part of breaks.

So the question we wanted to answer wasn't just "what is the best plan?" It was "what do you do when the plan breaks?"

Our first version used the graph wrong

Our first working version did this: FalkorDB found all the cycles, we pulled them into Python, an integer program (scipy's milp) picked the best non-overlapping set, and that was it. The graph was basically a fancy cycle finder.

When we stepped back, we didn't like it. Take FalkorDB out and most of the product still worked. So we rebuilt it around one idea: the graph should hold the state of the exchange, not just the input data.

How it works now

Every swap is a node

When a pool loads, Cypher stores every possible 2- and 3-way swap as its own node, linked to the pairs in it:

(:Cycle {key, members, len, hard, back_arcs, live, taken})-[:HAS {pos}]->(:Pair)
Enter fullscreen mode Exit fullscreen mode

For 300 pairs that's about 65,000 swaps, created in around 0.6 seconds. Each swap also stores two things we use later: how many hard-to-match patients it helps, and its number of "back-arcs" (more on those below).

A plan is guarded and then approved by a person

The agent drafts a plan. The integer program still does the choosing, but it now only chooses among swaps the graph says are alive and not already taken. Before anything is saved, a couple of Cypher queries check the plan: every donor-to-patient link still exists, and no pair appears twice.

FalkorDB's check of a drafted plan and the queries it ran
Every draft shows the check FalkorDB ran and each query behind it, with its time.

Then a person approves it. The AI has no tool for approving at all; that action only exists behind the web UI. When someone approves, the graph commits those pairs and marks every other swap that touches them as taken. At 300 pairs, that's 66,864 swaps updated in about 140 ms.

A plan approved by a person, with what the graph committed
Approval is a person's action, recorded with their name and time.

When a lab test fails

This is the part we're proudest of. A person records a positive crossmatch, say pair 1's donor can't give to pair 70's patient. One query finds every stored swap where pair 70 comes right after pair 1, marks it dead, and links it to the lab result that killed it:

MATCH (t:Crossmatch {id: $tid})
MATCH (a:Pair {id: $a})<-[h1:HAS]-(c:Cycle)-[h2:HAS]->(b:Pair {id: $b})
WHERE c.live AND h2.pos = (h1.pos + 1) % c.len
SET c.live = false
CREATE (t)-[:BROKE]->(c)
RETURN count(c)
Enter fullscreen mode Exit fullscreen mode

In our 200-pair demo, that kills 27 swaps, including one the coordinator had already approved: the ring 1 → 70 → 173. Its three patients are now stranded, so the graph releases them.

What the graph found after a positive crossmatch: 27 swaps died, 3 patients stranded, 1 rescue
One failed test: 27 swaps die, the approved ring 1 → 70 → 173 breaks, and the graph finds what can still be saved.

Then it looks for something to save. A 3-way ring sometimes has a "back-arc": a link going the other way between two of its pairs. If so, those two pairs can still do a simple 2-way swap even though the ring is broken. The UK's national exchange counts back-arcs for exactly this reason (Manlove and O'Malley, 2014). In our demo, pairs 1 and 173 have one, so there's a rescue.

The agent then drafts a repair from the swaps still alive, with the stranded patients weighted first. It re-matches pairs 1 and 173. Pair 70 is very hard to match and has no live option left, and the app just says so. A person approves the repair, and the graph records which plan it repaired and which lab result caused it.

The repair plan re-matching pairs 1 and 173
The repair re-matches two of the three stranded patients and says plainly that pair 70 has to wait.

The whole reaction to the failed test is eight small queries, about 40 ms. The UI shows that list of queries with their timings after every action, because we wanted people to see the graph doing the work.

"Why was this patient left out?"

This used to be a weak spot. Now it's a four-step walk in the graph: from the patient, to their possible swaps, to the other pairs in those swaps, to the chosen swap that's already using one of those pairs. The answer reads like: "Pair 5 has 193 possible swaps, but each one needs a pair that the best set already uses. The chosen swap 55>84>78 blocks 45 of them."

Why pair 5 is left out, with the swaps blocking it and the queries FalkorDB ran
The answer names the swaps in the way, and shows the four-step walk FalkorDB did to find them.

For patients with no short swap at all, shortestPath finds the shortest ring back to them. Sometimes the answer is simply that the donor can't give to anyone in the pool. In one test pool, four pairs had an AB donor, and AB donors can only give to AB patients. No swap of any length could include them.

What-ifs on a copy

"What if one more donor joined?" and "what if this pair dropped out?" run on a GRAPH.COPY of the live graph. We change the copy with Cypher, compare the results and drop it. The real data never leaves the database. We liked this a lot; it was simpler than anything we'd planned.

Keeping the AI honest

The agent uses an LLM, but with tight limits:

  • It can only call a fixed set of tools. Every tool is a human-written, parameterised Cypher query. The model never writes queries.
  • The same tools are served over MCP, so any MCP client gets the same rules.
  • If the answer mentions a patient that no tool actually returned, the answer is withheld.
  • Approving a plan and recording a lab result are human-only. They're not in the agent's tools or on the MCP server, and a test fails if they ever show up there.
  • Every answer carries its evidence. An empty result means "the graph doesn't know", never "safe".

The agent answering why pair 5 is not matched, with the tool it called
Asked in plain English, the model picked one fixed tool. The tool call and its result are shown under the answer.

We also didn't want to trust our own numbers blindly. A separate plain-Python recount, which never touches the database, has to agree with the graph's cycle counts and max-flow bound, or make verify fails.

Some numbers

Measured through the app's own tools on 300 pairs (make graph-bench, median of 5 fresh loads):

What the graph does Time
Store all 64,976 three-way swaps as nodes ~590 ms
Approval: mark 66,864 swaps as taken ~137 ms
Failed test: kill the swaps that used that link ~6 ms
Find the 2-way rescue inside a broken ring ~1 ms
"Why left out?" four-hop walk ~3 ms
GRAPH.COPY of the whole graph for a what-if ~270 ms
algo.maxFlow upper bound ~110 ms

What we learned about FalkorDB

Things we liked. Cycle-finding is a natural fit for Cypher patterns. It found tens of thousands of rings in under a second. GRAPH.COPY made what-ifs almost boring. algo.maxFlow gave us an upper bound without writing a solver. And getting started was one Docker container and a few lines of Python.

Things that tripped us up:

  1. Results get cut off silently. By default a query returns at most 10,000 rows, with no warning. Our cycle counts were wrong until we noticed and raised the limit.
  2. Some Cypher isn't supported. You can't put a pattern inside CASE or inside exists(), and shortestPath only works in WITH or RETURN. Each time, there was a workaround (OPTIONAL MATCH, or storing a list of members on the node), but it took some trial and error.
  3. Unclear errors from Python. Passing a numpy number instead of a plain Python int gave "Encountered unhandled type in inlined properties", without saying which parameter was wrong.
  4. Scale has a cost. After we added about 67,000 swap nodes, algo.maxFlow got roughly four times slower, even though it doesn't read those nodes. Storing every swap was still worth it for us, but it's a trade-off.

Things we'd love to see: a built-in way to find cycles up to a given length, a warning when results are truncated, and error messages that point at the exact problem.

Honest limits

  • All patients are synthetic. No real or personal data, no clinical validation, not medical advice.
  • The 2-way vs 3-way result is a known one. We reproduce it; we didn't discover it.
  • The graph builds, scores, checks and records the plan, but an integer program picks the swaps.
  • We react to failed tests. We don't predict which tests will fail.

Try it

We used AI coding assistants while building this, and we've written down exactly how in AI_USE.md in the repo.

If you work on kidney exchange or graph databases and something here looks wrong, we'd genuinely like to hear about it.

Top comments (0)