curl -s -X POST $SUBSTRIO_URL/v1/_graph/follows/edges -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"src":1,"dst":2}'
# {"label":"follows","src":1,"dst":2,"added":true}
A graph label is a set of directed edges between node ids (plain integers, not documents). A label is created the first time an edge is written to it.
Edges
| Operation | Route | Scope |
|---|---|---|
graph.label.list |
GET /v1/_graph |
read-only |
graph.edge.read |
GET /v1/_graph/{label}/edges or POST .../edges/_check |
read-only |
graph.edge.write |
POST /v1/_graph/{label}/edges |
curator |
graph.edge.remove |
POST /v1/_graph/{label}/edges/_remove |
curator |
Each edge call takes {"src": <node>, "dst": <node>}, where a node is an
integer or a document's _id (see "Graphs joined to documents"), and returns
{"label", "src", "dst", "exists"|"added"|"removed"}. Writing an edge that
already exists, or removing one that does not, is not an error. added or
removed is false in those cases.
An edge write may also carry a weight (any number from 0 up, default 1)
and a time (an integer, such as milliseconds since 1970). Writing an edge
that already exists with a weight or a time replaces both. A read of an
existing edge returns its weight and its time when set. Removing an edge
also removes its weight and time. A negative or non-finite weight returns 400
and writes nothing.
curl -s -X POST $SUBSTRIO_URL/v1/_graph/roads/edges -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"src":1,"dst":2,"weight":4.5,"time":1761800000000}'
# {"label":"roads","src":1,"dst":2,"added":true,"weight":4.5,"time":1761800000000}
curl -s $SUBSTRIO_URL/v1/_graph -H "Authorization: Bearer $SUBSTRIO_KEY"
# {"labels":["follows","blocks"],"count":2}
Neighbors, degree and stats
curl -s "$SUBSTRIO_URL/v1/_graph/follows/neighbors/1" -H "Authorization: Bearer $SUBSTRIO_KEY"
# {"label":"follows","node_id":1,"direction":"out","neighbors":[2,3],"count":2}
graph.neighbors (GET or POST /v1/_graph/{label}/neighbors/{id}) reads
the out-neighbors of a node by default; POST with body {"dir": "in"}
reads in-neighbors instead. graph.degree (GET /v1/_graph/{label}/degree/{id}) answers {"label", "node_id", "out_degree", "in_degree"}. graph.stats (GET /v1/_graph/{label}/stats) answers
{"label", "edge_count"}. A node with no edges answers zero counts and an
empty neighbor list; there is no separate not-found error for an unknown
node or edge.
Analytics
graph.analytics (GET or POST /v1/_graph/{label}/analytics) runs one
algorithm, named in body op, over a label. Every reply opens with {"op", "label"} plus the algorithm's own fields.
Pairwise similarity (take a, b): common_neighbors, jaccard,
adamic_adar, preferential_attachment, overlap_coefficient,
cosine_similarity, resource_allocation, exclusive_neighbors,
neighbor_union.
curl -s -X POST "$SUBSTRIO_URL/v1/_graph/follows/analytics" -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"op":"jaccard","a":1,"b":2}'
# {"op":"jaccard","label":"follows","a":1,"b":2,"dir":"out","score":0.4}
Whole-graph algorithms: pagerank, betweenness_centrality,
closeness_centrality, hits, scc (strongly connected components),
connected_components, louvain (community detection), triangle_count_global,
global_clustering_coefficient, topological_sort, has_cycle,
graph_summary, degree_histogram, degree_distribution.
Per-node and path algorithms (take node, or src/dst): degree_centrality,
clustering_coefficient, k_hop_neighbors, two_hop_neighbors,
shortest_path, shortest_path_length, all_shortest_paths, reachable,
eccentricity.
Recommendations for one node
Two analytics find recommendations for a single node. Each reads the part of the graph around that node.
similar_nodes (takes node) finds every node that shares a neighbor with it and ranks them. This is the "people you may know" query. score picks the measure: adamic_adar (the default), common_neighbors, jaccard, resource_allocation, cosine_similarity, or overlap_coefficient. Each candidate's score is exactly what the pairwise analytic of that name answers for the two nodes. exclude_linked (default true) leaves out the nodes it already links to. max_degree skips a shared neighbor with more than that many links on the other side, so one celebrity does not pull in half the graph (0, the default, skips none). top_k sets how many come back (default 20, at most 10,000).
curl -s -X POST "$SUBSTRIO_URL/v1/_graph/follows/analytics" -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"op":"similar_nodes","node":1,"score":"jaccard","top_k":3}'
# {"op":"similar_nodes","label":"follows","node":1,"dir":"out","score":"jaccard","exclude_linked":true,
# "max_degree":0,"nodes":[{"node":2,"score":0.5},{"node":4,"score":0.2}],"count":2}
personalized_pagerank (takes node, or seeds as a list) ranks nodes by how often a random walk that keeps returning to the seeds lands on them. This is the recommendation query behind "more like these". damping (default 0.85) is the chance the walk goes on rather than returning. epsilon (default 0.0001) sets the precision. A smaller value reads further from the seeds and gives finer scores. The walk reads at most 1 / (epsilon × (1 − damping)) nodes' links, whatever the graph's size. A node with no links sends its share back to the seeds. The scores of every node reached sum to 1. top_k works as above.
curl -s -X POST "$SUBSTRIO_URL/v1/_graph/follows/analytics" -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"op":"personalized_pagerank","seeds":[1,4],"top_k":5}'
# {"op":"personalized_pagerank","label":"follows","dir":"out","damping":0.85,"epsilon":0.0001,
# "seeds":[1,4],"nodes":[{"node":1,"score":0.31}, ...],"count":5}
A missing node, a top_k outside 1 to 10,000, a damping or epsilon outside 0 to 1, a negative max_degree, or an unknown score answers 400.
Weighted and timed walks
Four analytics read each edge's weight and time. An edge written without a weight weighs 1, so on a label that never had a weight they work on hop counts. They walk out edges.
weighted_shortest_path (takes src, dst) finds the cheapest path, where a path's cost is the sum of its edges' weights. It returns path and cost. When dst cannot be reached, path is empty and cost is -1. The search stops when it reaches dst.
k_shortest_paths (takes src, dst, k from 1 to 100, default 3) returns the k cheapest paths that never visit a node twice, cheapest first. Each includes its cost. When fewer paths exist, all available paths are returned.
curl -s -X POST "$SUBSTRIO_URL/v1/_graph/roads/analytics" -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"op":"k_shortest_paths","src":1,"dst":8,"k":2}'
# {"op":"k_shortest_paths","label":"roads","src":1,"dst":8,
# "paths":[{"path":[1,2,8],"cost":5.5},{"path":[1,4,8],"cost":7}],"count":2}
weighted_pagerank (takes iterations from 1 to 1,000, default 20, and damping from 0 up to but not including 1, default 0.85) is PageRank where each node passes its rank along its edges in proportion to their weights. An edge of weight 0 passes nothing. A node whose edges all weigh 0 shares its rank with every node, as a node with no edges does. When every weight is 1, the scores equal pagerank's.
hops_in_window (takes node, hops from 1 to 100, default 1) answers every node reached from node within that many hops, in ascending order, with node left out.
The three walks take a time window: from and to, both optional and both inclusive. With a window, a walk follows only the edges whose time falls inside it. An edge with no time is left out. With neither given, every edge is followed. A from after to answers 400.
curl -s -X POST "$SUBSTRIO_URL/v1/_graph/flights/analytics" -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"op":"hops_in_window","node":1,"hops":2,"from":1761800000000,"to":1761886400000}'
# {"op":"hops_in_window","label":"flights","node":1,"hops":2,"nodes":[2,3,4],"count":3}
dir ("out" or "in", default "out") applies to every directional
algorithm. An unlisted op, or a request missing the fields that op
needs, answers 400.
Graphs joined to documents
A graph can use a collection's document ids as its node ids. An edge call takes a document's _id as
src and dst, and a JavaScript graph call takes it as a node. Then a collection chain can walk the
graph and filter what it reaches by the documents' own fields in one query. There is no query language to
learn and no second request.
// People two hops from Ann over links made this year, who live in Paris.
db.collection("people").hops("follows", ann._id, { hops: 2, from: 1767225600000 }).eq("city", "Paris").all();
hops takes the label, one start node or several, how far to walk, the direction, a time window, and a
weight range. Its full reference is in the chain reference under "Graph conditions". Rust functions call
hops with the same options. A document whose id is past 4,294,967,295 cannot be a node.
How edges are stored
Every label keeps two sets for each node: the nodes it points to, and the nodes that point to it. Both sets live in the same index structure as a collection's field values. A node with a few links keeps each set as one sorted list in a single record, at 4 bytes a link. A node with many links nearby keeps one 256-bit map for each block of 256 nodes.
- Reading a node's neighbors, checking one edge, and reading its degree take no lock. A read is never held up by a write.
- Common neighbors and Jaccard similarity walk the two nodes' sets side by side. The cost follows the smaller set, not the number of nodes in the graph.
- Edge writes are applied one at a time. Each write changes both sets of its edge before the next write starts.
A label's name is read once per request. Labels survive a restart, and a whole-database copy takes every edge with it.
A weight and a time are kept apart from the two sets, in one record for each source node and each block of 256 targets. A label that never had a weight has no such records, and its reads and writes cost exactly what they did before. On a weighted label, a walk reads one record for each block of 256 targets of a node it visits. An app archive keeps each weighted edge's weight and time, and a restore puts them back.
How the analytics read the graph
An analytic reads only the sets it needs. Its cost follows the edges it touches, not the highest node id in the graph.
- A per-node or pairwise analytic reads the sets of the nodes it names. Common neighbors, triangle counts, clustering, and the similarity scores walk two sets side by side and skip the blocks of nodes that only one set holds.
- A path or hop analytic remembers the nodes it has visited in a set that grows with the walk. The shortest path length and
reachablewalk from both ends at once, and the smaller side takes the next step. Both answer for paths of up to 100 hops, as before. - The degree analytics (top nodes by degree, the degree histogram, distribution, percentile, and counts) read every set's size in one pass over the label. They read no node's neighbors.
- A whole-graph analytic (PageRank, HITS, betweenness, components, communities, coloring, and the global triangle count) reads the label once into arrays and then works on those arrays.
influence_spread counts every seed as activated, including a seed whose id is above every node in the label. Before, a seed above the highest node id the label had ever held was left out of the count.
Mutation safety
Edge writes are not versioned and carry no idempotency key, but they are
idempotent by nature: adding or removing the same edge twice has the same
effect as once (added/removed answers false the second time).