# Routing

Course: [Graph Engineering](https://www.devtutorials.ai/learn/graph-engineering) · Section: Graph Foundations
Source: https://www.devtutorials.ai/learn/graph-engineering/graph-foundations/routing

> Send each ticket down the right path: a cheap path for easy work, a careful path with a human gate for risky work, and a safe fallback when the router is unsure.

## Watch a Payment Change Take the Cheap Path

Forty tickets are waiting. Most are small: a docs fix, a version bump, a button. A few touch logins, payments or the database.

A graph routes them. A **router** node reads each ticket and says "low risk" or "high risk". Low-risk tickets go to a quick fix with a small model. High-risk ones go to a careful fix with a large model, then to a person for approval.

Run it and look for red lines.

Some auth and payment tickets were called "low risk" and took the quick path. Nothing crashed. The tests passed. Each one **completed successfully, down the wrong path**. That is how routing fails: quietly.

## TL;DR

- **Routing** sends each item down the path that matches its risk or difficulty. What it buys: **cost matched to risk**.
- The router is often the **weakest node**. A misrouted item finishes without an error.
- **Check the router first** on labelled examples. Aim for 90% or better.
- Give it a **safe fallback**: a `when: else` edge to the careful path.
- Send **unsure** items to the fallback with a **confidence threshold**.
- Don't route everything to the careful path. Safe, but it costs as much as having no router.

## A Real-World Example

Sam's team routes support tickets. A small model labels them: "billing", "bug", "question". Billing goes to a careful agent that can issue refunds only with a person's approval.

One week, refunds go out wrong. The cause: tickets that said "charged twice" were labelled "bug" by the small router and went to the bug path, which could change code but had no billing checks.

The team did three things. They tested the router on 200 old tickets with known labels: it was right 81% of the time. They switched it to a larger model: 94%. And they added a rule: when the router is unsure, the ticket goes to the careful path. Wrong refunds stopped.

## What You Will Learn

- What routing buys, and how it fails
- How to write conditional edges and a safe fallback
- How to check a router on labelled examples
- How a confidence threshold catches the router's mistakes
- How to balance safety against cost

## By Risk and Difficulty

A router looks at an item and picks the next edge. In the graph file, the edges from a router have a `when:` condition:

```yaml
edges:
  - { from: route, to: quick_fix, when: risk == low }
  - { from: route, to: careful_fix, when: risk == high }
  - { from: route, to: careful_fix, when: else }
  - { from: careful_fix, to: approve }
```

The two paths differ in what matters: the **model** (small or large), the **checks** (tests only, or tests plus review) and the **ending** (merge, or a person's approval). Routing is worth it only when the paths really differ like this.

The risk often comes from the **area** of the code:

```js runnable title="Risk from the area of the code"
const HIGH = ["auth", "payments", "migration"];
const tickets = [
  { id: 21, area: "docs" },
  { id: 22, area: "auth" },
  { id: 25, area: "payments" },
  { id: 27, area: "auth" },
  { id: 29, area: "migration" },
  { id: 30, area: "deps" },
];

const risk = (t) => (HIGH.includes(t.area) ? "high" : "low");
const counts = { low: 0, high: 0 };
for (const t of tickets) counts[risk(t)]++;
console.log(tickets.map((t) => "#" + t.id + " " + risk(t)).join(", "));
console.log("low: " + counts.low + ", high: " + counts.high);
```

Expected output:

```text
#21 low, #22 high, #25 high, #27 high, #29 high, #30 low
low: 2, high: 4
```

But in real work, the router does not get the area for free. It reads the ticket text and **guesses**. That guess can be wrong.

## Label the Tickets Yourself

Before a router labels anything, try it yourself. This is what a router does for every ticket, all day.

You probably got most right, and maybe one or two wrong. A router makes the same kind of mistakes, and you measure it the same way: against known answers.

## Check the Router First

Before you trust a router, test it on **labelled examples**: old tickets where you already know the right answer. Count how often it agrees.

```js runnable title="Test a router on labelled tickets"
const labelled = [
  { text: "Fix typo in README",              truth: "low" },
  { text: "Charge customers twice on retry", truth: "high" },
  { text: "Bump vite to 5.4.8",              truth: "low" },
  { text: "Session not cleared on logout",   truth: "high" },
  { text: "Align filter buttons",            truth: "low" },
];

// A weak router: only looks for a few scary words.
const weakRouter = (text) => (/password|payment|database/i.test(text) ? "high" : "low");

let right = 0;
for (const t of labelled) if (weakRouter(t.text) === t.truth) right++;
console.log("right on " + right + " of " + labelled.length + " = " + Math.round((right / labelled.length) * 100) + "%");
for (const t of labelled) if (weakRouter(t.text) !== t.truth) console.log("missed: " + t.text);
```

Expected output:

```text
right on 3 of 5 = 60%
missed: Charge customers twice on retry
missed: Session not cleared on logout
```

This weak router missed both risky tickets. "Charge twice" and "session not cleared" are high risk, but they contain none of its words. If you only watched it run, every ticket would finish "successfully".

A misrouted item does not fail loudly. It completes, the tests pass, and it is merged. The only way to see routing mistakes is to measure the router against known answers before you trust it.

In the lab, the router is checked on the 40 labelled tickets before each run. A **small** model is right about 80% of the time. A **large** model is right about 93% of the time. For a router that decides how carefully a payment change is treated, the larger model is worth paying for.

## A Safe Fallback

Even a good router is sometimes wrong. The fix is to know **when** it might be wrong. Routers can report a **confidence**: a number from 0 to 1 for how sure they are.

Add `min_confidence:` to the router. Any ticket below it takes the `else` edge, and `else` must go to the **careful** path.

```js runnable title="Route with a confidence threshold"
const MIN = 0.7;
const predictions = [
  { id: 2,  truth: "high", says: "low",  conf: 0.58 },
  { id: 5,  truth: "low",  says: "low",  conf: 0.92 },
  { id: 13, truth: "high", says: "high", conf: 0.88 },
  { id: 27, truth: "high", says: "low",  conf: 0.64 },
];

function route(p) {
  if (p.conf < MIN) return "careful (unsure)";
  return p.says === "high" ? "careful" : "quick";
}

for (const p of predictions) {
  const path = route(p);
  const bad = p.truth === "high" && path === "quick";
  console.log("#" + p.id + " " + p.truth + " risk -> " + path + (bad ? "   MISROUTED" : ""));
}
```

Expected output:

```text
#2 high risk -> careful (unsure)
#5 low risk -> quick
#13 high risk -> careful
#27 high risk -> careful (unsure)
```

Both wrong labels had low confidence, so both went to the careful path. Nothing risky took the quick path.

A fallback only works if the router is wrong **when it is unsure**. A small router is sometimes confidently wrong, and no threshold catches that. That is why the exercise needs both: a better router, and a fallback.

## Safe, but Not Too Safe

You could set `min_confidence: 0.99`. Then almost every ticket falls back to the careful path. Nothing risky slips through. But now easy tickets pay the careful price too, and a person must approve every docs fix.

That graph is as expensive as having no router at all, and it buries people in approvals they will start to click through without reading. Routing exists to match cost to risk. Keep the quick path busy with the work that belongs there.

```js runnable title="Cost of three routing settings"
const lowTickets = 31, highTickets = 9;
const QUICK = 1200, CAREFUL = 4000;

function cost(lowOnQuick) {
  const lowCareful = lowTickets - lowOnQuick;
  return lowOnQuick * QUICK + (lowCareful + highTickets) * CAREFUL;
}

console.log("no fallback (risky!):  " + cost(31).toLocaleString("en-US") + " tokens");
console.log("threshold 0.7:         " + cost(28).toLocaleString("en-US") + " tokens");
console.log("threshold 0.99:        " + cost(0).toLocaleString("en-US") + " tokens");
```

Expected output:

```text
no fallback (risky!):  73,200 tokens
threshold 0.7:         81,600 tokens
threshold 0.99:        160,000 tokens
```

### All Three Checks Together

The lab's check asks three things of a router graph at once. Here they are in code:

```js runnable title="The three routing checks"
const result = { accuracy: 0.93, highOnQuick: 0, lowOnQuick: 28, lows: 31 };

const checks = [
  ["router accuracy 90%+",         result.accuracy >= 0.9],
  ["0 high-risk on the quick path", result.highOnQuick === 0],
  ["half of low-risk on quick",     result.lowOnQuick / result.lows >= 0.5],
];
for (const [name, ok] of checks) console.log((ok ? "pass  " : "FAIL  ") + name);
```

Expected output:

```text
pass  router accuracy 90%+
pass  0 high-risk on the quick path
pass  half of low-risk on quick
```

In Claude Code, Codex and Cursor, a router is usually the main agent following `graph.md`, choosing which subagent to call. Give the routing rules and the fallback in plain words in `graph.md`, and set a stronger `model:` on the careful subagent.

## Lab Exercise: Make the Router Safe and Cheap

Open the graph file. Change it so that:

1. The router uses a stronger model: `model: large`.
2. The router has `min_confidence: 0.7`.
3. The `when: else` edge goes to `careful_fix`, not `quick_fix`.

Then press **Check my graph**. The check routes all 40 tickets on **3 versions**, each with different router mistakes. You need: router accuracy of 90% or more, **0** high-risk tickets on the quick path, and at least half the low-risk tickets on the quick path.

## Common mistakes

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| Cheap router | "Labelling is easy; use the smallest model." | The router is the weakest node; give it a strong model. | Risky tickets are called low risk. |
| Cheap fallback | "Unsure tickets are usually easy." | `else` goes to the careful path. | Every router mistake becomes a risky merge. |
| Never testing the router | "The runs look fine." | Measure accuracy on labelled examples. | Misroutes complete successfully and nobody notices. |
| Threshold too high | "Safer is always better." | Match cost to risk. | Everything is careful, and approvals become rubber stamps. |
| Routing with identical paths | "A router makes the graph smarter." | Route only if the paths differ. | Extra cost, no benefit. |

## Exercises

Each exercise ends with a value on the last line. Press **Check** to compare it with the expected answer.

## What to Remember

- Routing matches cost to risk. It fails quietly: a misroute completes successfully.
- The router is often the weakest node. Give it a strong model and measure it on labelled examples.
- Every router needs a `when: else` edge to the careful path.
- A confidence threshold sends the router's unsure guesses to the fallback.
- Too much caution costs as much as no router. Keep easy work on the quick path.

## Further Reading

- [How to Implement Graph Engineering, by QA Wolf](https://www.qawolf.com/blog/how-to-implement-graph-engineering-5-composable-patterns): routing, "the router is the weakest node", and the issue-triage worked example.
- [Building Effective Agents, by Anthropic](https://www.anthropic.com/engineering/building-effective-agents): routing easy questions to smaller models and hard ones to larger models.

Next: **Parallelization**. Three audit branches write the same field and one hangs forever, and you add one-writer fields, a timeout and a join policy.
