Escaping Absorbing States in Markov Route Generation

When a Markov route generator produces walks that all pile up in the same handful of nodes or terminate the instant they arrive somewhere, the cause is an unintended absorbing state — a node the chain can enter but never leave — hiding in the transition matrix.

Part of Markov Chain Routing Models: that stage samples routes from a state-transition matrix over a road or grid network, and this page isolates the single structural defect that most often ruins a run — states or whole regions of the state space that trap the walk, either as literal self-loops or as disconnected components with no outgoing edge to the rest of the graph.

Root Cause: Reachability Defects in the Transition Matrix

A Markov route generator draws the next node from the current node’s row of a row-stochastic transition matrix P\mathbf{P}, where PijP_{ij} is the probability of stepping from state ii to state jj and every row sums to one:

P=[P11P1NPN1PNN],jPij=1    i.\mathbf{P} = \begin{bmatrix} P_{11} & \cdots & P_{1N} \\ \vdots & \ddots & \vdots \\ P_{N1} & \cdots & P_{NN} \end{bmatrix}, \qquad \sum_{j} P_{ij} = 1 \;\; \forall i.

Achieved walk length under four absorbing-state fractions, against a 60-step request The horizontal axis is the number of steps a walk actually completed, from zero to the requested sixty; the vertical axis is the cumulative percentage of walks that stopped at or before that length. With no absorbing states the curve is flat at zero until the requested length and then jumps to one hundred per cent — every walk gets what it asked for. With one per cent of states absorbing, the curve lifts off the axis immediately and a substantial minority of walks has already stopped well before sixty steps. At three and eight per cent the curves rise steeply and the majority of walks never reach the requested length at all. Each curve is labelled with the mean achieved length. The point the labels make is the diagnostic one: the mean moves far less than the distribution does, so a gate that checks mean trajectory length against a tolerance will pass a corpus in which a third of the walks were silently truncated. The check that catches it is the share of walks that reached the requested length, which should be exactly one hundred per cent. The mean barely moves; the distribution is destroyed 0 15 30 45 60 0% 25% 50% 75% 100% steps actually completed walks stopped by this length (%) 0% sinks · mean 60 steps · 100% reached 60 1% sinks · mean 56 steps · 86% reached 60 3% sinks · mean 33 steps · 26% reached 60 8% sinks · mean 9 steps · 0% reached 60 0% absorbing 1% absorbing 3% absorbing 8% absorbing 4,000 walks of a requested 60 steps over 400 nodes, fixed seed. A mean-length tolerance passes a corpus in which a third of the walks were truncated. The check that catches it is the share reaching the requested length, and the only acceptable value is 100%.
A mean-length tolerance passes a corpus in which a third of the walks were truncated; the share reaching the requested length does not.

A state ii is absorbing when Pii=1P_{ii} = 1: once entered, the chain re-selects the same state on every subsequent draw and the route freezes. That is the obvious case. The subtler and more common defect is a disconnected component — a set of states CC for which Pij=0P_{ij} = 0 for every iC,jCi \in C, j \notin C. The chain is still perfectly row-stochastic, every row sums to one, and no single diagonal entry is one, yet a walk that wanders into CC can never return to the rest of the network. From the sampler’s point of view the whole component behaves as one giant absorbing region.

These defects rarely come from bad math; they come from how the matrix is built. A road-network matrix assembled from observed transition counts inherits the observation gaps: a cul-de-sac visited only inbound gets a row that, after normalization, points only back the way the walk came or loops on itself. A grid clipped to a study-area boundary leaves border cells whose only recorded neighbours fell outside the clip, so they normalize to a self-loop. Merging two tiles whose node ids were assigned independently can leave a bridge edge missing, splitting what should be one network into two components that never exchange walks. Unlike the sparse-count dead-ends handled in simulating pedestrian movement with first-order Markov models — which produce zero-sum rows that truncate the walk — absorbing states and disconnected components produce valid row-stochastic matrices, so a row-sum check passes them straight through. Detecting them requires reasoning about reachability on the directed graph, not about the arithmetic of any single row.

Reachability defects in a Markov route network: self-loop absorbing state and disconnected component Left panel shows the raw directed graph of route states as circles connected by arrows. A large strongly connected component of five nodes routes freely among itself. One node has a self-loop arrow back to itself and no outgoing edge elsewhere, marked as an absorbing state that freezes the walk. A separate pair of nodes forms a disconnected component with an edge only between themselves and no edge back to the main component, marked as a trap. Right panel shows the cleaned graph after strongly-connected-component analysis: only the largest strongly connected component is retained for sampling, the self-loop node is either whitelisted as a true sink or merged, and the disconnected pair is dropped. Raw transition graph After SCC analysis S self-loop Pₛₛ=1 disconnected: no edge in/out largest SCC retained sink whitelisted or merged disconnected pair dropped
A row-stochastic matrix can still trap walks: self-loops freeze them and disconnected components swallow them. Strongly-connected-component analysis exposes both so only a mutually reachable core is sampled.

Minimal Reproducer: Surface the Traps

Row-sum arithmetic will not find these defects, so build the directed reachability graph and inspect its strongly connected components. Pin the toolchain so component ordering is deterministic.

# requirements.txt — pin majors, let patch releases float
numpy==1.26.*
scipy==1.11.*
networkx==3.*
python
import numpy as np
import networkx as nx
from scipy import sparse

def diagnose(P: sparse.csr_matrix, whitelist: set[int] | None = None) -> dict:
    """Find unintended absorbing states and disconnected components in a route matrix."""
    whitelist = whitelist or set()
    n = P.shape[0]
    diag = P.diagonal()
    self_loops = set(np.flatnonzero(diag >= 1.0 - 1e-9)) - whitelist  # P_ii == 1
    # Build directed graph from non-zero off-diagonal entries.
    coo = P.tocoo()
    g = nx.DiGraph()
    g.add_nodes_from(range(n))
    for i, j, v in zip(coo.row, coo.col, coo.data):
        if i != j and v > 0:
            g.add_edge(int(i), int(j))
    sccs = list(nx.strongly_connected_components(g))
    largest = max(sccs, key=len)
    trapped = n - len(largest)                       # nodes outside the main core
    return {"unintended_self_loops": len(self_loops),
            "num_sccs": len(sccs),
            "largest_scc": len(largest),
            "nodes_outside_core": trapped}

# A matrix merged from two tiles with a missing bridge edge:
# {'unintended_self_loops': 3, 'num_sccs': 47, 'largest_scc': 812, 'nodes_outside_core': 191}

Any unintended_self_loops, or a largest_scc that is much smaller than NN, means walks will truncate or pool in traps. A healthy routing matrix has one dominant strongly connected component covering nearly every node.

Fix: Restrict to the Largest Component and Whitelist True Sinks

The remedy has two parts: keep only the mutually reachable core so no walk can enter a trap, and distinguish genuine destinations (which should absorb) from accidental sinks (which must be repaired). Never “fix” a self-loop by silently redistributing its probability — that manufactures transitions the data never observed.

Four reachability defects in a routing graph, with the test and repair for each Four panels, each a small directed graph. The first shows a true sink: a node with incoming edges and no outgoing edge. It is detected by a zero out-degree check, and the repair depends on intent — a genuine destination is whitelisted and the walk is recorded as complete, while an artifact of sparse data gets a smoothing floor. The second shows a one-way trap: a small group of nodes reachable from the main network with no path back. It is not detected by out-degree, because every node in it has outgoing edges; it needs a strongly-connected-component decomposition, and the repair is to keep only the largest component. The third shows a disconnected component: nodes with no path to or from the rest, usually a digitising error or an island. The same decomposition finds it, and it is dropped with its removal recorded. The fourth shows a periodic cycle: a component that is strongly connected but whose cycle lengths share a common divisor, so the walk visits its states in a fixed rotation and never reaches a stationary distribution. It is detected by computing the greatest common divisor of its cycle lengths, and the repair is to add a small self-loop probability, which makes the chain aperiodic without changing where it goes. Only the first is found by an out-degree check true sink found by: zero out-degree fix: whitelist, or floor it one-way trap found by: SCC decomposition fix: keep the largest component disconnected part found by: SCC decomposition fix: drop it, and record the removal periodic cycle found by: gcd of cycle lengths fix: add a small self-loop Three of the four are invisible to the check most pipelines actually run. A single strongly-connected-component decomposition finds two of them and costs one pass over the edge list.
Only the first of the four is found by the out-degree check most pipelines actually run.

Step 1 — Restrict to the largest strongly connected component

A strongly connected component is a maximal set of nodes where every node can reach every other. Restricting the matrix to it guarantees the chain is irreducible: from any state, any other state is reachable, so no walk can be trapped.

python
import numpy as np
import networkx as nx
from scipy import sparse

def restrict_to_core(P: sparse.csr_matrix, node_ids: list) -> tuple[sparse.csr_matrix, list]:
    """Keep only the largest SCC, then renormalize rows to stay stochastic."""
    coo = P.tocoo()
    g = nx.DiGraph()
    g.add_nodes_from(range(P.shape[0]))
    for i, j, v in zip(coo.row, coo.col, coo.data):
        if i != j and v > 0:
            g.add_edge(int(i), int(j))
    core = max(nx.strongly_connected_components(g), key=len)
    keep = sorted(core)                              # fixed order -> reproducible index
    sub = P[keep][:, keep].tocsr()
    row_sums = np.asarray(sub.sum(axis=1)).ravel()
    inv = np.divide(1.0, row_sums, out=np.zeros_like(row_sums), where=row_sums > 0)
    sub = sparse.diags(inv) @ sub                    # renormalize surviving rows to sum to 1
    return sub.tocsr(), [node_ids[i] for i in keep]

Step 2 — Whitelist true sinks, absorb the walk gracefully

Some absorbing states are correct: a depot, a parking terminus, a trip end. Those belong on an explicit whitelist so the sampler stops there rather than looping, and the diagnostic ignores them. Everything not whitelisted that still looks absorbing is a defect to repair upstream.

python
def resolve_sinks(P, node_ids, true_sinks: set, sampler_stop: set) -> set:
    """Return the index set the sampler should treat as legitimate terminals.

    true_sinks: real-world destinations (depots, trip ends) -> STOP here.
    Everything else with no meaningful out-degree is a build defect, not a sink.
    """
    id_to_idx = {nid: i for i, nid in enumerate(node_ids)}
    stop = {id_to_idx[s] for s in true_sinks if s in id_to_idx}
    sampler_stop.update(stop)
    return sampler_stop  # sampler breaks on these; all other states remain transient

With the matrix restricted to its core and true sinks whitelisted, the sampler from the parent routing stage can run unbounded without ever freezing: every non-terminal state has a path back into the network, and every terminal state is one you intended. Attach dwell times and trip ends downstream in the temporal synchronization for moving objects stage rather than encoding them as chain structure.

Verification Step: Gate Irreducibility

Wire irreducibility and sink-legitimacy as CI assertions so a re-merged network cannot regress into traps. Score the retained coverage the same way the realism metrics evaluation stage scores synthetic against source, so a silent mass deletion of nodes fails loudly.

python
import numpy as np
import networkx as nx

def test_route_matrix_is_escapable(P, node_ids, whitelist_idx, min_core_frac=0.95):
    # 1. Exactly one dominant strongly connected component covering most nodes.
    coo = P.tocoo()
    g = nx.DiGraph(); g.add_nodes_from(range(P.shape[0]))
    g.add_edges_from((int(i), int(j)) for i, j, v in zip(coo.row, coo.col, coo.data)
                     if i != j and v > 0)
    core = max(nx.strongly_connected_components(g), key=len)
    assert len(core) / P.shape[0] >= min_core_frac, "network fragmented into traps"

    # 2. No unintended absorbing states outside the whitelist.
    diag = P.diagonal()
    leaked = set(np.flatnonzero(diag >= 1.0 - 1e-9)) - set(whitelist_idx)
    assert not leaked, f"unmodeled absorbing states: {sorted(leaked)}"

    # 3. Rows still sum to one after restriction/renormalization.
    row_sums = np.asarray(P.sum(axis=1)).ravel()
    assert np.abs(1.0 - row_sums[row_sums > 0]).max() < 1e-9, "renormalization drift"

The coverage assertion is the load-bearing one. Restricting to the largest component always yields an irreducible matrix, so the risk after the fix is the opposite failure — throwing away most of the network because a missing bridge edge split it — and only a coverage floor catches that.

Edge Cases & Gotchas

A legitimate destination looks exactly like a defect. A depot where every trip ends has genuinely no meaningful outgoing transition, so the diagnostic flags it identically to an accidental sink. The only way to tell them apart is domain knowledge encoded as the whitelist; never auto-repair flagged self-loops, because doing so injects transitions out of a real terminus that the source data never contained and corrupts the trip-end distribution.

Weak vs. strong connectivity across one-way networks. On a road graph with one-way streets, a region can be weakly connected (reachable ignoring direction) yet not strongly connected (no directed path back). Using weakly connected components hides one-way traps: a walk drives into a one-way pocket and cannot legally return. Always use strongly connected components for directed route matrices; weak connectivity is only safe on an undirected walkable grid.

Renormalization after edge deletion shifts probabilities. Dropping the edges that led into removed components leaves the surviving rows summing to less than one, and renormalizing redistributes that mass across the remaining neighbours — subtly changing the transition preferences you calibrated. Log the total probability mass removed per row; if any row lost a large fraction, the node sat on the boundary of a dropped component and its behaviour after renormalization no longer reflects the source data.

Frequently Asked Questions

How is an absorbing state different from a zero-sum dead-end row?
A zero-sum row sums to zero, so inverse-transform sampling has no valid next state and the walk truncates immediately with what is effectively an error. An absorbing state has a valid row that sums to one but points only back to itself, so the walk continues forever selecting the same node. A row-sum check catches the first and passes the second, which is why absorbing states need reachability analysis on the directed graph.
Why use strongly connected components instead of weakly connected ones?
Weak connectivity ignores edge direction, so it treats a one-way pocket as connected even though a walk that enters it can never legally drive back out. Strong connectivity requires a directed path in both directions between every pair of nodes in the component, which is exactly the guarantee a route sampler needs. On a directed transition matrix, only the largest strongly connected component is safe to sample without traps.
Can I just add a small self-transition or restart probability to every node?
Adding a uniform restart probability, as in PageRank teleportation, does make the chain irreducible and technically removes traps, but it injects transitions the source data never observed and flattens the real route structure toward uniform. It is a modeling choice, not a bug fix. Prefer restricting to the reachable core, which preserves observed transition preferences exactly and only removes nodes that were genuinely unreachable.
My largest component dropped from 5000 nodes to 900 after the fix. What happened?
A sharp drop means the network was fragmented into many components, almost always from a build-time defect: node ids assigned independently per tile, a missing bridge edge between merged regions, or a clip that severed connections. The fix is upstream in the matrix assembly, not in the sampler. Log the component-size distribution and inspect the boundary between the two largest components to find the edge that should exist but does not.