Traversal results are frames. That is the whole reason they compose: you can filter a hop, sort it, join attributes onto it, or hop again.
k-hop reachability
ur.hop returns an EdgeFrame whose src is the seed and whose dst is the reached node.
reachable = ur.hop(edges, n=2).from_([0]).collect()
.from_(...) restricts the seed set. It takes a list of ids, or a NodeFrame — so the seeds can
themselves be the result of a computation:
hubs = edges.nodes().with_columns(indeg=ur.degree(edges, direction="in")).filter(
ur.col("indeg") > 100
)
ur.hop(edges, n=2).from_(hubs).distinct().collect()
n= is the hop count and direction= is "out" (default), "in" or "both".
Multiplicity follows the general rule: a hop keeps duplicates, one row per path found, and you
call .distinct() if you want the reachable set. Being explicit here is consistent with edges
being rows rather than a set.
Shortest path
route = ur.shortest_path(edges, source=17, target=42).collect()
Returns an EdgeFrame of (src, dst, hop, cost) — one row per edge on the path, in order, with
hop the zero-based position. Unweighted, this is BFS, and cost is the hop count so the schema
stays uniform.
Pass weight= and it becomes Dijkstra over the edge-cost expression, with cost the cumulative
cost from the source — the final row carries the total path cost:
route = ur.shortest_path(
edges,
source=17, target=42,
weight=ur.col("latency_ms"),
).collect()
total = route.to_dicts()[-1]["cost"] # what the cheapest path actually costs
An unreachable target returns an empty frame rather than raising.
Random walks
walks = ur.random_walk(
edges,
start=[0, 1, 2],
steps=10,
walks_per_node=4,
seed=7,
).collect()
Returns a frame of (walk_id, step, node) — the shape node2vec-style embedding pipelines want,
which you can hand straight to to_arrow() and out.
seed= makes a run reproducible. Ursa’s determinism guarantee is stronger than the usual one:
same seed means the same result regardless of thread count, not merely at fixed parallelism.
Walks currently draw from one serial RNG stream precisely to hold that guarantee.
Composing on a traversal
Because a hop is an EdgeFrame, the relational tail works on it:
(ur.hop(edges, n=2)
.from_([0])
.sort("dst")
.head(50)
.collect()
.to_polars())
And because the result is a frame, a traversal can feed the next stage of an ordinary dataframe pipeline rather than needing to be unpacked into Python objects first.
What is not here yet
Motif finding — ur.find("(a)-[e]->(b); ..."), GraphFrames-style — is the first post-v0.1
feature, not a v0.1 omission. Subgraph views over a filtered frame (a bitmask over the parent
CSR instead of a rebuild) are planned; today a graph op over a filtered EdgeFrame raises, and the
workaround is to materialize the filtered edges into a new frame.