Graph Algorithms¶
Shortest Path¶
result = graph.shortest_path(source_type='Person', source_id=1, target_type='Person', target_id=100)
if result:
for node in result["path"]:
print(f"{node['type']}: {node['title']}")
print(f"Connections: {result['connections']}")
print(f"Path length: {result['length']}")
Lightweight variants when you don’t need full path data:
graph.shortest_path_length(...) # → int | None (hop count only)
graph.shortest_path_ids(...) # → list[id] | None (node IDs along path)
graph.shortest_path_indices(...) # → list[int] | None (raw graph indices, fastest)
Which options each method takes. Every member of the family accepts the same traversal controls, so they all answer the same question:
Method |
|
|
|---|---|---|
|
yes |
yes |
|
yes |
no |
|
yes |
no |
|
yes |
yes |
|
yes |
no |
|
yes |
no |
|
yes |
no |
|
yes |
no |
source_type / target_type / node_type are an ID namespace. They say
which type to look the endpoint id up in — they never restrict which node
types the path may pass through. So this:
graph.shortest_path_length("Person", 3, "Person", 4)
happily answers 2 through a City node, because two people who live in the
same city are two hops apart. If you meant “how far apart through people”,
say so:
graph.shortest_path_length("Person", 3, "Person", 4, via_types=["Person"])
# → None
graph.shortest_path_length("Person", 3, "Person", 4, connection_types=["KNOWS"])
# → None
via_types restricts the intermediate nodes only — the two endpoints are
always allowed, whatever their type.
These methods are undirected by default. Edges are traversed both ways
whatever direction they were created in, unless you pass direction:
graph.shortest_path_length("Person", 1, "Person", 4) # 2 (either way)
graph.shortest_path_length("Person", 1, "Person", 4, direction="outgoing") # 3 (forwards only)
graph.shortest_path_length("Person", 1, "Person", 4, direction="incoming") # None
direction accepts 'outgoing' / 'out', 'incoming' / 'in', and
'any' / 'both' / None (the default). Anything else raises — it is never
silently ignored. This is the same vocabulary traverse() and
where_connected() use. Cypher’s shortestPath() expresses the same thing
with the arrow in the pattern ((a)-[:KNOWS*..10]->(b) is directed,
(a)-[:KNOWS*..10]-(b) is not); see the shortestPath() section of
CYPHER.md.
Weighted shortest path¶
Pass weight_property to switch from BFS (hop count) to Dijkstra (sum of edge weights). Edges missing the property default to weight 1.0; negative weights cause the path to be reported as missing. The weighted search honours connection_types, via_types and direction exactly as the unweighted one does.
# Cheapest path by edge.cost
result = graph.shortest_path(
"Stop", "A", "Stop", "Z",
weight_property="cost",
)
# {'path': [...], 'connections': [...], 'length': 3, 'weight': 4.7}
# Length-only variant returns float when weighted, int otherwise
graph.shortest_path_length("Stop", "A", "Stop", "Z", weight_property="cost") # → 4.7
Batch variant for computing many distances at once — it builds the adjacency once for the whole batch, so it is far cheaper than a loop:
distances = graph.shortest_path_lengths_batch('Person', [(1, 5), (2, 8), (3, 10)])
# → [2, None, 5] (None where no path exists, same order as input)
# Same filters as the rest of the family
graph.shortest_path_lengths_batch(
'Person', [(1, 5), (2, 8)],
connection_types=['KNOWS'], direction='outgoing',
)
A pair whose endpoint the filters exclude entirely (a person with no KNOWS
edge, under connection_types=['KNOWS']) answers None — the same “no path”
a disconnected pair gets, never an error.
One source, many targets¶
shortest_path_lengths_from() walks outward from a single source once and
returns {node id: hop count} — the one-to-many shape, where the batch is the
many-to-many one:
# Every Person within 3 hops of Alice.
graph.shortest_path_lengths_from('Person', 1, 'Person', max_hops=3)
# → {1: 0, 2: 1, 5: 2, 4: 2}
# An answer for exactly these ids, None where unreachable.
graph.shortest_path_lengths_from('Person', 1, target_ids=[4, 6])
# → {4: 2, 6: None}
The two shapes are deliberately different, and the difference is the whole contract:
With
target_idsyou get one entry per requested id, in the order you gave them, and an unreachable target maps toNone. You asked about it, so you get an answer for it.Without
target_ids(discovery mode) you get only the nodes actually reached. Absent means unreachable — or beyondmax_hops. There are noNonevalues, because listing every unreached node in the graph is exactly the footgun this mode exists to avoid.
For the same reason, at least one of target_ids, target_type or max_hops
is required; an unbounded one-to-all walk is refused with a message naming the
three bounds.
target_type filters the result (and is the id namespace target_ids are
looked up in). It does not restrict the walk — via_types does that, and a
node via_types excludes is still reported with its own distance while
nothing is reached through it, exactly as the pair members exempt their
endpoints.
Unlike the pair members, a timeout_ms expiry raises here rather than
answering None: a dict silently missing its far half is a wrong answer, not
a missing one.
All Paths¶
paths = graph.all_paths(
source_type='Play', source_id=1,
target_type='Wellbore', target_id=100,
max_hops=4,
max_results=100 # Prevent OOM on dense graphs
)
Connected Components¶
components = graph.connected_components()
# Returns list of lists: [[node_dicts...], [node_dicts...], ...]
print(f"Found {len(components)} connected components")
print(f"Largest component: {len(components[0])} nodes")
graph.are_connected(source_type='Person', source_id=1, target_type='Person', target_id=100)
# True exactly when shortest_path_length() with the same arguments returns a
# distance — so it takes the same filters and direction.
graph.are_connected('Person', 1, 'Person', 100, connection_types=['KNOWS'])
Cypher procedures: scoped subgraph algorithms¶
Several algorithms are also exposed as Cypher CALL procedures so you can
run them over a subgraph — one node type and one (or several) relationship
types — instead of the whole graph. This is the idiomatic way to ask
“components among Person nodes connected by KNOWS” without first
extracting a separate graph.
All three share the same optional {node_type, relationship} scoping. Each
field accepts a string or a list of strings; omit the map to run over the
whole graph.
Edge-scope key:
relationshipandconnection_typesare interchangeable on every algorithm procedure — the centrality/community procedures historically readconnection_typesand the components/k-core ones readrelationship, but either term now works anywhere. Unknown config keys are rejected with a did-you-mean (CALL pagerank(): unknown config key 'connection_typ'. Did you mean 'connection_types'?) rather than silently producing an empty result. (wherepredicate-scoping is supported by the centrality + community procedures; the components/k-core/clustering group scopes bynode_type+relationshiponly.)
Connected components¶
-- Whole graph
CALL connected_components() YIELD node, component
RETURN component, count(*) AS size ORDER BY size DESC
-- Scoped to one node type + relationship
CALL connected_components({node_type: 'Person', relationship: 'KNOWS'})
YIELD node, component
RETURN component, collect(node.name) AS members
-- Multiple relationship types
CALL connected_components({node_type: ['Person'], relationship: ['KNOWS', 'OWNS']})
YIELD node, component
RETURN count(DISTINCT component) AS num_components
K-core decomposition (coreness)¶
The coreness of a node is the largest k for which it survives in the
k-core (the maximal subgraph where every node has degree ≥ k). High
coreness marks structurally central, resilient nodes. k_core and coreness
are aliases.
CALL k_core() YIELD node, coreness
RETURN node.name AS name, coreness ORDER BY coreness DESC LIMIT 10
-- Scoped
CALL k_core({node_type: 'Person', relationship: 'KNOWS'})
YIELD node, coreness
RETURN coreness, count(*) AS n ORDER BY coreness DESC
Local clustering coefficient¶
The fraction of a node’s neighbour pairs that are themselves connected — the
local triangle-closure rate (0.0 = no neighbours linked, 1.0 = neighbourhood
is a clique). clustering_coefficient and local_clustering_coefficient are
aliases.
CALL clustering_coefficient() YIELD node, coefficient
RETURN node.name AS name, coefficient ORDER BY coefficient DESC
-- Scoped, then averaged
CALL clustering_coefficient({node_type: 'Person', relationship: 'KNOWS'})
YIELD node, coefficient
RETURN avg(coefficient) AS global_avg
Scoping is computed lazily over the live graph (no copy), so these run identically across the in-memory, mapped, and disk storage modes.
Centrality Algorithms¶
All centrality methods return a ResultView of {type, title, id, score} rows, sorted by score descending.
graph.betweenness_centrality(top_k=10)
graph.betweenness_centrality(normalized=True, sample_size=500)
graph.pagerank(top_k=10, damping_factor=0.85)
graph.degree_centrality(top_k=10)
graph.closeness_centrality(top_k=10)
# Alternative output formats
graph.pagerank(to_df=True) # → DataFrame with type, title, id, score columns
# An {id: score} mapping, when you want one (ids are unique per type only, so
# key by (type, id) whenever the selection spans more than one node type)
scores = {r["id"]: r["score"] for r in graph.pagerank().to_dicts()}
Community Detection¶
# Louvain modularity optimization (recommended)
result = graph.louvain_communities()
# {'communities': {0: [{type, title, id}, ...], 1: [...]},
# 'modularity': 0.45, 'num_communities': 2}
for comm_id, members in result['communities'].items():
names = [m['title'] for m in members]
print(f"Community {comm_id}: {names}")
# With edge weights and resolution tuning
result = graph.louvain_communities(weight_property='strength', resolution=1.5)
# Label propagation (faster, less precise)
result = graph.label_propagation(max_iterations=100)
Clustering¶
General-purpose clustering via Cypher CALL cluster(). Reads nodes from a preceding MATCH clause.
# Spatial DBSCAN — auto-detects lat/lon from set_spatial() config
result = graph.cypher("""
MATCH (f:Field)
CALL cluster({method: 'dbscan', eps: 50000, min_points: 2})
YIELD node, cluster
RETURN cluster, count(*) AS n, collect(node.name) AS fields
ORDER BY n DESC
""")
# Property-based K-means — cluster on explicit numeric properties
result = graph.cypher("""
MATCH (w:Wellbore)
CALL cluster({
properties: ['totalDepth', 'bottomHoleTemp'],
method: 'kmeans', k: 5, normalize: true
})
YIELD node, cluster
RETURN cluster, count(*) AS n
""")
Parameter |
Type |
Default |
Notes |
|---|---|---|---|
|
string |
|
|
|
list |
(none) |
If omitted, uses spatial config |
|
float |
0.5 |
DBSCAN neighborhood radius (meters for spatial, raw units for properties) |
|
int |
3 |
DBSCAN minimum neighbors for core point |
|
int |
5 |
K-means cluster count |
|
int |
100 |
K-means iteration limit |
|
bool |
false |
Min-max scale features to [0,1] before clustering |
Noise points (DBSCAN only) get cluster = -1. Filter with WHERE cluster >= 0.
Analytics¶
Statistics¶
price_stats = graph.select('Product').statistics('price')
unique_cats = graph.select('Product').unique_values(property='category', max_length=10)
# Group by a property — like SQL GROUP BY
graph.select('Person').count(group_by='city')
# → {'Oslo': 42, 'Bergen': 15, 'Trondheim': 8}
graph.select('Person').statistics('age', group_by='city')
# → {'Oslo': {'count': 42, 'mean': 35.2, 'std': 8.1, 'min': 22, 'max': 65, 'sum': 1478},
# 'Bergen': {'count': 15, ...}, ...}
Calculations¶
graph.select('Product').calculate(expression='price * 1.1', store_as='price_with_tax')
graph.select('User').traverse('PURCHASED').calculate(
expression='sum(price * quantity)', store_as='total_spent'
)
graph.select('User').traverse('PURCHASED').count(store_as='product_count', group_by_parent=True)
Node Degrees¶
degrees = graph.select('Person').degrees()
# Returns: {'Alice': 5, 'Bob': 3, ...}