Lesson 16 · Exposure-BFS · Deeper Track

How risk flows through the graph

The last L6 deferral: weight propagation, hop by hop. ~11 min.

Builds on: L6 · L13 Anchor: contagion / cascades New: multiplicative decay New: weighted BFS

L6 said exposure is "a BFS over risk edges, bounded by depth and a threshold." We deferred how. Now we open it — and it's a remarkably small, elegant algorithm answering a question you reason about intuitively: "if token X fails, how exposed is a vault three hops away — and by how much?" The answer is a weight that starts at 1.0 and decays as it travels.

Your anchor: contagion
You already think in cascades — a depeg drains the pools holding it, which dents the vaults allocated to those pools, which strains the markets using those vaults as collateral. Influence fades with each hop: a vault directly holding X is hammered; one three hops away feels a tremor. Exposure-BFS is exactly this, quantified — the academic Bardoscia financial-contagion model (the code says so: "Bardoscia model with equity normalization and delta propagation").

1 · The core idea: a decaying weight

Each node gets an exposure weight to the source: 1.0 means "fully exposed" (the source itself); 0 means "untouched." The weight starts at 1.0 at the source and, every hop, is multiplied by the edge's weight (each edge weight ≤ 1). So influence shrinks multiplicatively with distance:

1.0source X
×0.9HOLDS
0.9pool
×0.6VAULT_ASSET
0.54vault
×0.4LEND_COLL
0.22market
<minDelta
pruned

(Illustrative weights.) A node's RiskUSD exposure is then simply weight × that node's USD price — how many dollars of it ride on the source's fate.

2 · The algorithm (the whole thing fits on a screen)

It's a plain weighted BFS (pkg/risk/bfs.go computeNodeExposure):

// start: the source node is fully exposed to itself
queue := []item{{idx: startIdx, depth: 0, weight: 1.0}}
visited[startIdx] = true

for len(queue) > 0 {
    cur := queue[0]; queue = queue[1:]        // FIFO → breadth-first

    // record this node's exposure — MAX over paths, not sum
    entry.RiskScore = math.Max(entry.RiskScore, cur.weight)
    entry.RiskUSD   = math.Max(entry.RiskUSD, cur.weight*node.USDPrice)

    if cur.depth >= maxDepth { continue }       // MaxBFSDepth = 6
    for _, e := range forwardEdges {
        if visited[e.Target] { continue }
        w := cur.weight * e.Weight                // ← multiplicative decay
        if w < minDelta { continue }            // minDelta = 1e-6 → prune
        visited[e.Target] = true
        queue = append(queue, item{e.Target, cur.depth+1, w})
    }
}

Source: pkg/risk/bfs.go (computeNodeExposure, minDelta=1e-6, MaxBFSDepth=6, RiskEdges). The "Bardoscia model with equity normalization" note is the header docstring on the ExposureComputer.

3 · Four design decisions worth dwelling on

① Multiplicative decay w = cur.weight × e.Weight

Because every edge weight ≤ 1, the product only ever shrinks. That is the attenuation model — distance dilutes exposure automatically, no special distance term needed. Two strong links (0.9 × 0.9 = 0.81) propagate more than one weak one (0.4); a chain of weak links dies fast.

② Max over paths, not sum (you've seen this before)

entry.RiskScore = max(entry.RiskScore, cur.weight) — a node's exposure is the weight of its strongest path to the source, not the sum over all paths. This is the same anti-double-counting choice as L13's max-over-attack-channels (and why a single position can't blow past its real value into the ">$1T bug" territory). Sum would inflate; max is the honest worst-single-path.

③ Single-visit BFS is exact here (a subtle, satisfying point)

visited[] is set on first enqueue, so each node is processed once — via its shortest path (BFS). Normally "first visit" ≠ "best." But because weights only decay, the fewest-hops path carries the highest weight. So the shortest path is the max-weight path, and single-visit BFS computes the exact maximum — no need to explore every route. The decay model and the BFS order line up perfectly.

④ The two bounds make an exponential problem linear

BoundValueEffect
MaxBFSDepth6Never traverse past 6 hops — risk that far is negligible.
minDelta1e-6Drop any path whose weight falls below this — the real tractability lever.

The minDelta prune is what keeps the frontier bounded: in a graph where a node can have thousands of edges, most propagation paths decay below 1e-6 within a couple of hops and are abandoned. Without it, BFS would fan out combinatorially. This is the analytical cousin of L2's "bound depth, no full scans" — here enforced by the weight itself.

4 · What it traverses, and which way

Two scoping choices:

Incremental, like everything else
Exposure isn't recomputed for the whole graph each cycle — ComputeForNodes recomputes only the nodes a graph delta touched (the "delta propagation" in the model name), and stamps the results back as fields the rule engine can alert on. Same push-based, incremental discipline as L6/L13.

The risk engine, now fully open
You've now seen every part of L6: AT_RISK cells (failure-source → victim attribution), the per-field math (max / noisy-OR / HHI, L13), and exposure propagation (decaying weighted BFS, here). No remaining black box in the analytics layer.

Check yourself

1. As exposure propagates one hop, the weight is…
2. A node's RiskScore exposure to the source is recorded as the max over arriving paths because…
3. Why is single-visit BFS (visited-on-enqueue) exact for max exposure here, not just an approximation?
4. What is the main mechanism that keeps the BFS frontier from exploding?
5. Exposure propagates over RiskEdges (HOLDS, POOL_ASSET, VAULT_ASSET, …) but NOT over ADMIN_CTRL. Why?
6. A node 3 hops out reached via edge weights 0.5 × 0.5 × 0.5, with USD price $2M, has RiskUSD exposure ≈…
7. The exposure computer is described as the "Bardoscia model with … delta propagation." The "delta propagation" part means…
8. Conceptually, the MaxBFSDepth + minDelta bounds are the analytical version of which earlier rule?
↳ Ask your teacher
Try: "Where do the per-edge weights (e.Weight) come from?" · "What's the Bardoscia model, in one paragraph?" · "How does DepthBreakdown get used downstream?" · "Show me ComputeForNodes' incremental entry point."

What you can now do

L6 is fully open — and so, essentially, is the system
With exposure propagation explained, the risk engine has no remaining black box, and your end-to-end model of risk-graph-indexer is complete down to the algorithms: ingest → graph → enrich → risk (cells · field math · exposure) → rules → alerts, with the streaming, single-writer, recovery, and observability scaffolding underneath. 16 lessons; the whole machine.

Grounded in: pkg/risk/bfs.go (computeNodeExposure weighted BFS, w = cur.weight*e.Weight, max-over-paths, minDelta=1e-6, MaxBFSDepth=6, RiskEdges, Bardoscia/equity-normalization + delta-propagation docstring), exposure.go (ComputeForNodes incremental entry). Verify against source — the code is the truth.