An edge list looks like nothing. It is dense with structure.
Ursa is a dataframe API for graph analytics. PageRank, centralities, components and traversals over edges in Parquet or CSV — in memory, on one machine. Rust and Apache Arrow underneath.
v0.2.0, on PyPI. The kernels, the composed pipeline and the relational surface execute end to end; the roadmap says what does not.
pagerank
ur.pagerank(edges, damping=0.85, max_iter=30, tol=1e-6, weight=None) → NodeFrame
Returns a lazy frame of id and pagerank. Nothing runs until .collect(), so the scan, the algorithm and the sort are planned and executed as a single DataFusion query — with the graph operator as a first-class node in that plan, not a step orchestrated around it.
The colour in the table is the only colour on this page. It is a temperature scale, and it appears because there is finally something measured to show.
import ursa as ur
import pyarrow as pa
edges = ur.from_arrow(
pa.table({"s": [0, 1, 2, 3, 4, 5, 2, 6, 7],
"d": [1, 2, 0, 4, 5, 3, 3, 0, 0]}),
src="s", dst="d",
)
(ur.pagerank(edges, damping=0.85)
.sort("pagerank", descending=True)
.head(5)
.collect()
.to_polars())| id | in_degree | pagerank |
|---|---|---|
| 0000003 | 2 | 0.238271 |
| 0000004 | 1 | 0.221280 |
| 0000005 | 1 | 0.206838 |
| 0000002 | 1 | 0.102843 |
| 0000001 | 1 | 0.098933 |
Tab. 1 — top 5 of 8 by pagerank over the 9 edges above, damping 0.85, iterated to convergence. Computed, not illustrated.
One plan, not two engines.
There is no Graph object. An EdgeFrame is the graph, a NodeFrame is an attribute table, and every operation returns a frame — so metrics, attributes, filters and top-k compose into one lazy pipeline instead of a script that hands results between libraries.
The topology index is built once on first use and shared by every graph operation over that frame. Property-only transformations keep it; row-changing ones drop it. That contract is the performance model, and it is written down rather than guessed at.
nodes = ur.scan_nodes("towers.parquet", id="tower_id")
edges = ur.scan_edges("links.parquet",
src="tower_a", dst="tower_b")
(nodes
.with_columns(
pagerank = ur.pagerank(edges, damping=0.85),
component = ur.connected_components(edges),
nbr_cap = ur.neighbors(edges).agg(
ur.col("capacity_gbps").mean()),
)
.filter(ur.col("pagerank") > 0.001)
.sort("pagerank", descending=True)
.head(20)
.collect())One collect(). Three kernels over one shared topology index, joined to the node attributes, filtered and ranked inside the engine — and the node file is read for only the columns the plan proves it needs.
What the instrument does today.
Algorithms
pagerank · degree · connected_components (weak and strong) · triangle_count · clustering_coefficient · closeness · betweenness (Brandes, sampled) · label_propagation · louvain — each with a weighted variant, deterministic across thread counts.
Traversals
hop().from_() · shortest_path (BFS, or Dijkstra when weighted, with a cumulative cost column) · random_walk — each its own first-class logical node, each returning a frame.
Relational verbs
filter (a full predicate algebra) · select · with_columns · sort · head · distinct · sample · rename · group_by().agg() · join — the same frame, both vocabularies.
Sources
Parquet and CSV — local, object storage (s3:// · gs:// · az://) or https:// — with the column projection pushed into the file. Or straight from data: EdgeFrame(dict | rows | polars | pandas | pyarrow), from_edgelist, from_networkx, from_numpy, from_scipy_sparse, and ur.datasets for canonical graphs. Node ids may be int64 or strings.
Statistics
describe (one-row summary frame) · density · avg_path_length · diameter — the eager scalars are the one deliberate exception to laziness.
Egress
to_polars · to_arrow · to_dicts · sink_parquet · sink_csv. Arrow is the boundary in both directions, so leaving Ursa costs nothing.
Not yet
schema() · store= · CSV format options · neighbors(from_=) — each raises a clear error rather than mis-executing, as do the stated composition limits. Motif finding and the optimizer rules are ahead.
The visualizer inverts.
You cannot read a negative on a live instrument. So where the site is paper, the visualizer is sky: black ground, bright sources, colour throughout — because everything on screen has been measured. Same tokens, flipped.
In design, not shipped. The figure below is a specimen of the rendering conventions, generated and measured at build time like every other figure here.
Five rules, and nothing else.
Astronomers read plates as negatives — black sources on a white sky. So the site is paper and the instrument is sky. It also puts Ursa on the opposite side of every dark dev-tool launch.
The page is ink until something has been measured. Then the temperature scale appears, and only ever means a value. No brand accent, no coloured buttons, no highlight colour.
Radius, weight and spike length all encode score, on one stretch, across the mark, the hero, the docs and the app — which is why the asterism is found in the field rather than placed on it.
Figures carry a single mono line: what it is, how big, how it was processed. No corner ticks, no metadata rails, no status bars — and no figure that cannot state real numbers.
A text serif and a mono. No sans anywhere, which is the whole reason it reads as published rather than shipped.