N or P
Contents Glossary
09

Routing when paths can have negative costs

Negative-cost paths break the standard router. The fix is slower, but it stays cheap.

Some routing problems involve paths with negative cost. Currency exchange arbitrage is the classic example — a sequence of trades can return more than you started with, expressible as a negative-cost cycle in a graph. Refund flows in finance, time-shifted scheduling where doing a task earlier saves money, and constraint systems where some inputs offset others all share this shape. The standard shortest-path algorithm fails on negative costs — it can be tricked into committing to a path that looks short locally but is actually long. The Bellman-Ford algorithm handles negative costs at a higher computational cost — work grows with the network size times the number of edges, not the logarithm of nodes. It also detects when a negative-cost cycle exists, which is the signal for "your input data has an arbitrage opportunity." Rust's petgraph implements it as a single function call.

  1. 01
    Currency arbitrage as a graph: each edge weight is -ln(rate). bellman_ford either returns the shortest-path table or signals a negative cycle — and the negative cycle is the arbitrage opportunity, not an error.
    use petgraph::algo::bellman_ford;
    use petgraph::graph::{DiGraph, NodeIndex};
    
    fn detect_arbitrage(fx: &DiGraph<&str, f32>, from: NodeIndex) -> Result<(), &'static str> {
        match bellman_ford(fx, from) {
            Ok(_paths) => Ok(()),
            Err(_) => Err("negative cycle — arbitrage opportunity exists"),
        }
    }
  2. 02
    match is Rust's pattern-matching expression — it picks one branch out of several based on the shape of a value. When that value is an enum (a type with named variants), each branch handles one variant, and the compiler refuses to compile until every variant is covered. This exhaustiveness check is the safety feature: you cannot forget a case. Each arm can also bind the variant's payload — Ok(x) pulls the success value out into x, Err(e) pulls the error out into e. The underscore _ is a wildcard pattern that matches anything and binds nothing.
    // An enum has named variants, each optionally carrying data.
    enum Direction { North, South, East, West }
    
    match d {
        Direction::North => "up",
        Direction::South => "down",
        Direction::East  => "right",
        Direction::West  => "left",
    }
    // Delete any arm and the compiler refuses — exhaustiveness check.
    
    // Variants can carry data; match arms destructure it out.
    enum Shape {
        Circle(f64),         // radius
        Rect(f64, f64),      // width, height
    }
    
    let area = match s {
        Shape::Circle(r)  => 3.1416 * r * r,
        Shape::Rect(w, h) => w * h,
    };
    
    // _ is the wildcard — matches anything, binds nothing.
    match d {
        Direction::North => "up",
        _                => "not north",
    }
  3. 03
    Option<T> is the standard library's "this might be a value, or might be nothing." Two variants: Some(T) carries a T, None carries nothing. Rust uses Option everywhere a more permissive language would use null — looking up a key in a HashMap returns Option<&V>, the first element of a slice is Option<&T>, finding a substring is Option<usize>. The compiler will not let you read the inner value without first handling the None case, which is the whole point.
    enum Option<T> {
        Some(T),
        None,
    }
    
    let scores: HashMap<&str, i32> =
        HashMap::from([("alice", 90), ("bob", 75)]);
    
    let alice = scores.get("alice");   // Some(&90)
    let chad  = scores.get("chad");    // None
    
    match alice {
        Some(score) => println!("alice got {}", score),
        None        => println!("alice not in the map"),
    }
    
    // Common shorthands instead of writing match by hand:
    let s        = alice.copied().unwrap_or(0);      // value or fallback
    let doubled  = alice.map(|x| x * 2);             // transform if Some
    let exists   = alice.is_some();                  // → bool
  4. 04
    Result<T, E> is the standard library's "operation might succeed with a T, or fail with an E." Two variants: Ok(T) for success, Err(E) for failure. Reach for Result when the caller needs to know why something failed (parsing, I/O, anything that can go wrong in interesting ways); reach for Option when the only outcomes are "got it" and "didn't." On this page, bellman_ford returns a Result — Ok carries the table of shortest distances, Err signals a negative cycle (no shortest path exists because you could loop the cycle forever). The wrapper here re-packages the error as a plain string because the caller does not need the full detail.
    enum Result<T, E> {
        Ok(T),
        Err(E),
    }
    
    // Typical use — parse a string as an integer.
    let parsed: Result<i32, _> = "42".parse();
    match parsed {
        Ok(n)  => println!("got the number {}", n),
        Err(e) => println!("not a number: {}", e),
    }
    
    // Page 9's use — bellman_ford returns Result<Paths, NegativeCycle>.
    match bellman_ford(fx, from) {
        Ok(_paths) => Ok(()),                                  // no arbitrage
        Err(_)     => Err("negative cycle — arbitrage exists"),
    }
    
    // Rule of thumb:
    //   Option<T>     ← "is the value here or not?"
    //                   null, missing key, no match, empty list
    //   Result<T, E>  ← "did the operation work, and if not, why?"
    //                   parsing, I/O, math errors, validation failures
Bellman, R. (1958) On a Routing Problem. Ford, L. (1956) Network Flow Theory. Source →
In plain terms

Most routing problems have non-negative costs and the standard library handles them. But a surprising number of business problems have negative-cost edges that you might not initially recognize as routing problems at all.

Currency arbitrage is the textbook case. Take the logarithm of each exchange rate and negate it. A profitable cycle of trades — buy USD with EUR, EUR with JPY, JPY back to USD with more than you started — becomes a negative-cost cycle in a graph of currency pairs. Detecting whether arbitrage exists is exactly the question Bellman-Ford answers.

The same shape shows up in financial netting (some balances are debts, some are credits, find the cheapest way to settle them all). In time-shifted scheduling (doing a task this week saves cost compared to next week). In constraint difference systems (every constraint of the form "x must be at most y plus 5" is an edge with weight 5; the system is solvable iff there are no negative cycles).

The standard router (Dijkstra, page 08) cannot handle this. It commits to paths based on local cost decisions, and a single negative edge later in the graph can invalidate those decisions. Bellman-Ford takes a different approach — it relaxes every edge in the graph repeatedly, enough times to guarantee that every shortest path has been found. This is slower than Dijkstra but still polynomial — work grows with the product of nodes and edges, not exponentially.

The negative-cycle detection is often the most valuable output. If your input data has negative cycles, your "find the cheapest path" question has no answer (you could go around the cycle forever, getting cheaper). But the existence of the cycle is itself useful information — it tells you the system has an arbitrage opportunity, a scheduling inconsistency, or a constraint violation.

In Rust, this is petgraph's bellman_ford function. Returns the distances or an error indicating the negative cycle. Twenty lines of glue and your team has the answer.

When the standard router does not fit, the slower router does — and stays in the cheap-to-solve category.

← 08 Routing — the shortest path through a network
9/39
10 Distance from everywhere to everywhere →