Migrating to 3.0 ​
algorithms 3.0.0 has one API: every algorithm takes a frozen GraphSnapshot from @graphty/graph-format and returns typed arrays indexed by node (or edge) index. The id-keyed API of 2.x -- the Graph class, the functions that took it and returned Maps and records keyed by node id, and the CSRGraph helpers -- is gone. The snapshot functions that 2.x offered as indexed.<name> are now top-level exports; indexed still works in 3.x as a deprecated alias of them and goes in 4.0.
Build a snapshot instead of a Graph ​
GraphBuilder takes the same addNode(id) and addEdge(source, target, weight) calls as the Graph class did. freeze() turns it into the snapshot every algorithm takes. Node index i is the i-th id the builder saw.
import { GraphBuilder } from "@graphty/graph-format";
import { dijkstra, pageRank } from "@graphty/algorithms";
// 2.x: const graph = new Graph({ directed: true }); graph.addEdge("a", "b", 1); ...
const builder = new GraphBuilder({ directed: true });
builder.addEdge("a", "b", 1);
builder.addEdge("b", "c", 2);
builder.addEdge("a", "c", 4);
const s = builder.freeze();
// 2.x: dijkstra(graph, "a").get("c")?.distance
const paths = dijkstra(s, s.ids.requireIndex("a"));
console.log(paths.dist[s.ids.requireIndex("c")]); // 3
// 2.x: pageRank(graph).ranks -- now one score per node index; toMap keys it by id again
const ranks = s.ids.toMap(pageRank(s).scores);
console.log(ranks.get("c")?.toFixed(3)); // 0.521Read a result by node id ​
A result holds one entry per node index. Three reads cover almost every 2.x shape:
- a score per node (2.x
Record<string, number>orMap):s.ids.toMap(result.scores) - a list of node indices (a path, a traversal order, a cut side):
Array.from(indices, (i) => s.ids.idOf(i)) - a partition (2.x
NodeId[][]orMap<string, number>):result.labels[i]is nodei's group andresult.groups()lists each group's node indices
import { GraphBuilder } from "@graphty/graph-format";
import { breadthFirstSearch, connectedComponents } from "@graphty/algorithms";
const builder = new GraphBuilder({ directed: false });
builder.addEdge("a", "b");
builder.addEdge("b", "c");
builder.addEdge("x", "y");
const s = builder.freeze();
// 2.x: breadthFirstSearch(graph, "a").order
const bfs = breadthFirstSearch(s, s.ids.requireIndex("a"));
console.log(Array.from(bfs.order.subarray(0, bfs.visitedCount), (i) => s.ids.idOf(i))); // ["a", "b", "c"]
// 2.x: connectedComponents(graph) -- one array of ids per component
const components = connectedComponents(s);
console.log(components.groups().map((members) => Array.from(members, (i) => s.ids.idOf(i)))); // [["a", "b", "c"], ["x", "y"]]Every 2.x function and class ​
In the right-hand column s is the snapshot, a and b are node indices (s.ids.requireIndex(id)), and r is the result of the call on the same row. Where the name is unchanged, the arguments and the result changed as above.
| 2.x | 3.0 |
|---|---|
adamicAdarForPairs(graph, pairs) | adamicAdarForPairs(s, { sources, targets }): one score per pair |
adamicAdarPrediction(graph, options) | adamicAdarPrediction(s, options): r.sources, r.targets, r.scores, best first |
adamicAdarScore(graph, u, v) | adamicAdarScore(s, a, b) |
allPairsShortestPath(graph) | allPairsShortestPath(s): r.dist[a * r.n + b] |
astar(map, start, goal, heuristic) | astar(s, a, b, (i, target) => estimate): r.path, r.distance; a Map-of-Maps becomes a snapshot first |
astarWithDetails(map, start, goal, heuristic) | astar(s, a, b, heuristic): r.gScore, r.fScore, r.visited as well |
bellmanFord(graph, source) | bellmanFord(s, a): r.dist, r.hasNegativeCycle, r.pathTo(b) |
bellmanFordPath(graph, source, target) | bellmanFord(s, a), then r.pathTo(b) and r.dist[b] |
betweennessCentrality(graph, options) | betweennessCentrality(s, options): r.scores |
bipartitePartition(graph) | isBipartite(s): r.sides is a node mask, the nodes of one side |
breadthFirstSearch(graph, start, options) | breadthFirstSearch(s, a, { target, maxDepth, arcOrder }): r.order, r.depth, r.parent |
calculateMCLModularity(graph, communities) | modularity(s, labels): Newman's modularity over weighted degrees (see below) |
closenessCentrality(graph, options) | closenessCentrality(s, options): r.scores |
commonNeighborsForPairs(graph, pairs) | commonNeighborsForPairs(s, { sources, targets }) |
commonNeighborsPrediction(graph, options) | commonNeighborsPrediction(s, options) |
commonNeighborsScore(graph, u, v) | commonNeighborsScore(s, a, b) |
compareAdamicAdarWithCommonNeighbors(...) | compareAdamicAdarWithCommonNeighbors(s, edges, nonEdges), pairs as { sources, targets } |
condensationGraph(graph) | condensation(s): r.components and the condensed graph r.condensed |
connectedComponents(graph) | connectedComponents(s): r.labels, r.count, r.groups() |
connectedComponentsDFS(graph) | connectedComponents(s) (members come in node order, not depth-first order) |
createBipartiteFlowNetwork(left, right, edges) | bipartiteFlowNetwork(left, right, edges): r.snapshot, r.source, r.sink |
degreeCentrality(graph, options) | degreeCentrality(s, options): one value per node |
depthFirstSearch(graph, start, options) | depthFirstSearch(s, a, { target, order: "pre" | "post", arcOrder }) |
dijkstra(graph, source) | dijkstra(s, a): r.dist, r.pathTo(b) |
dijkstraPath(graph, source, target) | dijkstra(s, a), then r.pathTo(b); or bidirectionalDijkstra(s, a, b) |
edgeBetweennessCentrality(graph, options) | edgeBetweennessCentrality(s, options): one score per edge index (undirected: twice 2.x, see below) |
edmondsKarp(graph, source, sink) | maxFlow(s, a, b, { algorithm: "edmonds-karp" }): r.maxFlow, r.flow per edge |
eigenvectorCentrality(graph, options) | eigenvectorCentrality(s, options): r.scores |
evaluateAdamicAdar(graph, edges, nonEdges) | evaluateAdamicAdar(s, edges, nonEdges), pairs as { sources, targets } |
evaluateCommonNeighbors(...) | evaluateCommonNeighbors(s, edges, nonEdges) |
findAllIsomorphisms(graph1, graph2) | findAllIsomorphisms(s1, s2): each mapping is the image in s2 of every node of s1 |
findStronglyConnectedComponents(graph) | stronglyConnectedComponents(s) (Tarjan's order, not Kosaraju's) |
floydWarshall(graph) | allPairsShortestPath(s, { method: "floyd-warshall", paths: true }) |
floydWarshallPath(graph, source, target) | allPairsShortestPath(s, { paths: true }), then r.pathTo(a, b) and r.dist[a * r.n + b] |
fordFulkerson(graph, source, sink) | maxFlow(s, a, b, { algorithm: "ford-fulkerson" }) |
getConnectedComponent(graph, node) | connectedComponents(s): the nodes whose r.labels equal r.labels[a] |
getKCore(graph, k) | kCoreDecomposition(s): the nodes with r.coreness[i] >= k |
getTopAdamicAdarCandidatesForNode(graph, node) | getTopAdamicAdarCandidatesForNode(s, a, options) |
getTopCandidatesForNode(graph, node) | getTopCandidatesForNode(s, a, options) |
girvanNewman(graph, options) | girvanNewman(s, options): one label vector per level in r.levels, and r.modularity |
greedyBipartiteMatching(graph) | greedyBipartiteMatching(s): r.matching[left] is its partner, r.size |
grsbm(graph, config) | grsbm(s, options) |
hasCycleDFS(graph) | hasCycle(s) |
hasNegativeCycle(graph) | bellmanFord(s, a).hasNegativeCycle from every node not yet reached, or allPairsShortestPath(s).hasNegativeCycle |
hierarchicalClustering(graph, linkage) | hierarchicalClustering(s, { linkage }): merges in r.left, r.right; r.cut(height) lists clusters |
hits(graph, options) | hits(s, options): r.hubs, r.authorities |
isBipartite(graph) | isBipartite(s).bipartite |
isConnected(graph) | connectedComponents(s).count === 1 |
isGraphIsomorphic(graph1, graph2) | isGraphIsomorphic(s1, s2): r.isomorphic, r.mapping |
isStronglyConnected(graph) | stronglyConnectedComponents(s).count === 1 |
isWeaklyConnected(graph) | weaklyConnectedComponents(s).count === 1 |
kCoreDecomposition(graph) | kCoreDecomposition(s): r.coreness, r.maxCore, r.cores() |
kargerMinCut(graph, iterations) | kargerMinCut(s, { iterations, randomSeed }): r.cutValue, r.side, r.cutEdges |
katzCentrality(graph, options) | katzCentrality(s, options): r.scores |
kruskalMST(graph) | kruskalMST(s): r.edges (edge indices), r.totalWeight |
labelPropagation(graph, options) | labelPropagation(s, options): r.labels, r.count |
labelPropagationAsync(graph, options) | labelPropagation(s, options); labelPropagationSynchronous(s, options) updates every node at once |
labelPropagationSemiSupervised(graph, seeds) | labelPropagationSemiSupervised(s, seeds, options), one seed label per node index |
largestConnectedComponent(graph) | connectedComponents(s).groups(), the longest one |
leiden(graph, options) | leiden(s, options): r.labels, r.modularity |
louvain(graph, options) | louvain(s, options): r.labels, r.modularity |
markovClustering(graph, options) | markovClustering(s, options): r.labels, r.attractors |
maximumBipartiteMatching(graph, options) | maximumBipartiteMatching(s, { left, right }), sides as node masks |
minSTCut(graph, source, sink) | minSTCut(s, a, b): r.cutValue, r.side, r.cutEdges |
minimumSpanningTree(graph) | kruskalMST(s) |
nodeBetweennessCentrality(graph, node) | betweennessCentrality(s).scores[a] |
nodeClosenessCentrality(graph, node) | nodeClosenessCentrality(s, a, options) |
nodeDegreeCentrality(graph, node) | degreeCentrality(s)[a], or s.degree()[a] for one node |
nodeEigenvectorCentrality(graph, node) | eigenvectorCentrality(s).scores[a] |
nodeHITS(graph, node) | hits(s), then r.hubs[a] and r.authorities[a] |
nodeKatzCentrality(graph, node) | katzCentrality(s).scores[a] |
nodeWeightedClosenessCentrality(graph, node) | nodeClosenessCentrality(s, a, { weighted: true }) |
numberOfConnectedComponents(graph) | connectedComponents(s).count |
pageRank(graph, options) | pageRank(s, options): r.scores, r.iterations, r.converged |
pageRankCentrality(graph, options) | pageRank(s, options).scores |
personalizedPageRank(graph, nodes) | personalizedPageRank(s, weights), one restart weight per node index |
primMST(graph, start) | primMST(s, { start, forest }): r.edges, r.totalWeight |
shortestPathBFS(graph, source, target) | breadthFirstSearch(s, a, { target: b }), then follow r.parent from b |
singleSourceShortestPath(graph, source) | dijkstra(s, a, { cutoff }): r.dist |
singleSourceShortestPathBFS(graph, source) | breadthFirstSearch(s, a): r.depth and r.parent |
spectralClustering(graph, options) | spectralClustering(s, options): r.labels, r.eigenvalues |
stoerWagner(graph) | stoerWagner(s) |
stronglyConnectedComponents(graph) | stronglyConnectedComponents(s): r.labels, r.groups() |
syncClustering(graph, config) | syncClustering(s, options) |
teraHAC(graph, config) | teraHAC(s, options) |
topPageRankNodes(graph, k) | pageRank(s).scores, then the k node indices with the highest score |
topologicalSort(graph) | topologicalSort(s): node indices, or null when the graph has a cycle |
transitiveClosure(graph) | allPairsShortestPath(s, { weighted: false }): b is reachable from a when r.dist[a * r.n + b] is finite |
weaklyConnectedComponents(graph) | weaklyConnectedComponents(s) |
weightedClosenessCentrality(graph, options) | closenessCentrality(s, { weighted: true }) |
DeltaPageRank (class) | new DeltaPageRank(s), over a snapshot; to follow a changed graph, freeze it again |
PriorityDeltaPageRank (class) | new PriorityDeltaPageRank(s), as DeltaPageRank |
DirectionOptimizedBFS (class) | directionOptimizedBfs(s, a), one source per call |
The rest of the 2.x surface went with the Graph class: Graph (use GraphBuilder and fromEdgeArrays from graph-format), toSnapshot (build the snapshot directly), CSRGraph, toCSRGraph, isCSRGraph, createOptimizedGraph, configureOptimizations, getOptimizationConfig, GraphBitSet, VisitedBitArray and CompactDistanceArray (a snapshot is already compressed sparse rows), heuristics and pathfindingUtils (a heuristic is now a function of two node indices), and the id-keyed result and option types. The Indexed-prefixed type names of 2.x (IndexedPageRankOptions, IndexedMinCutResult and the rest) are now the plain names (PageRankOptions, MinCutResult). PriorityQueue, UnionFind, ConvergenceError, PathWalkError and accelerated are unchanged, except for the closeness member of the accelerator seam (see "Sampled closeness" below).
Results that differ from 2.x ​
Most 3.0 functions return what the 2.x function of the same algorithm returned, in a new shape. These do not, on the graphs named:
- Ties.
dijkstra,bellmanFordandastarmay take another of two equally short paths, andprimMSTanother of two equally light trees: they break ties by row order where 2.x used insertion order. Distances and total weights are the same. - Order.
stronglyConnectedComponentslists components in Tarjan's order, wherefindStronglyConnectedComponentsused Kosaraju's;connectedComponentslists members in node order, whereconnectedComponentsDFSused depth-first order;isBipartitereturns an unordered side mask wherebipartitePartitionlisted the sides in search order. PassarcOrderto a traversal to try neighbours in insertion order. - Edge betweenness. On an undirected graph
edgeBetweennessCentralitygives each edge twice the value 2.x gave it: 2.x stored every undirected edge under both"u-v"and"v-u"and put half the edge's score on each key, where 3.0 returns one score per edge, the whole sum (on the edges1-2, 2-3, 3-1, 3-4, 4-5, 2.x gave0.5, 1.5, 1.5, 3, 2per key and 3.0 gives1, 3, 3, 6, 4). Add the two keys of 2.x to compare. Directed graphs are unchanged. - Centrality.
pageRankis a power iteration where 2.x ran the delta engine, soiterationscan be one more or one fewer and scores differ in the seventh decimal place.pageRankon an undirected graph returns scores (each edge carries rank both ways), where 2.x threw "PageRank requires a directed graph".eigenvectorCentralityconverges and returns scores on some graphs where 2.x threw aConvergenceError(mostly directed graphs with self-loops); where both converge, scores agree within 1e-4.katzCentralitywith a negativebetagives other scores (2.x rescaled from 0, not from the largest score). - Louvain and Leiden visit nodes in another seeded order and stop at other partitions (on the karate club, Louvain now reaches modularity 0.4188 against 0.3404).
- Label propagation. For the same
randomSeed, communities differ on any graph with more than one valid partition (a work queue, a uniform tie draw and another random generator); it stops when every label is dominant, so tie-rich graphs converge; in-neighbours vote on a directed graph; self-loops do not vote; a negative, NaN or infinite weight throws aRangeError.labelPropagationSemiSuperviseddraws from another random stream and renumbers the labels, and since the fix of issue #959 its free nodes start unlabeled instead of in a community of their own, so seed labels spread to every node connected to a seed rather than stopping where the free nodes outvote them.labelPropagationSynchronousadds a swap guard, so a single edge settles in two passes. In every release after 3.1.4 it breaks a tie by a fixed scramble of each label rather than by the lowest label, so its partitions differ from 3.0.0 through 3.1.4 on any graph with tied votes: a path or a cycle now settles in a few passes into short runs of nodes, where it used to need about two passes per node (949 communities andconverged: falseat the default cap on a 1,000-node path) and then end as one community. - All-pairs shortest paths (
floydWarshall,floydWarshallPath,transitiveClosure): the cheapest of parallel edges sets the distance; a node's distance to itself is 0 even with a positive self-loop; under a negative cycle every distance is NaN and no path is returned; a negative undirected edge is a negative cycle; a NaN or infinite weight throws.allPairsShortestPathrefuses more than 5,792 nodes unlessmaxNodesis raised. - Link prediction. Adamic-Adar scores agree with 2.x within 1e-9, but tied pairs rank in another order, so a
topKcut can keep other pairs and the evaluation metrics can differ where a held-out edge ties a non-edge. - Clustering.
modularityis Newman's modularity over weighted degrees, wherecalculateMCLModularityignored weights and summed the null model over the edges only.spectralClusteringsolves the Laplacian to a tolerance and gives other clusters on most graphs.teraHACindexes its distances by node, where 2.x looked them up byparseInt(nodeId)and merged arbitrarily after the first merge on most graphs.markovClusteringthrows aRangeErroron options and weights 2.x ran with: anexpansionthat is not a whole number of at least 1, aninflationthat is not finite and above 0, amaxIterationsthat is not a whole number, and a negative, NaN or infinite edge weight. - Self-loops in community detection. On an undirected graph with a self-loop,
girvanNewmancounts the loop twice in its node's degree where 2.x counted it once, so each level's modularity differs and the best level can be another one;grsbmgives another root modularity (on one small graph with a loop, 0 where 2.x gave 0.0586). - Seeded
grsbmandsyncClustering. Both draw from mulberry32 in every release after 3.1.2. 2.x and 3.0.0 through 3.1.2 drew from a linear congruential generator whose float64 product lost its low bits above 2^53 and which could return exactly 1, so the sameseednow gives other bisections, clusters and embeddings. grsbmbisects along the Fiedler vector. 2.x, and 3.x before the fix for issue #975, sorted each cluster by the eigenvector of its Laplacian's largest eigenvalue, so its splits were close to random. Each split now follows the Fiedler vector (the smallest nonzero eigenvalue's): a two-block planted partition splits exactly along its blocks, and Zachary's karate club scores modularity 0.40 where it scored 0.04. The clusters,spectralValuesand modularity scores differ from 2.x's on almost every graph.syncClusteringscales each node's step by its degree. 2.x, and 3.x before the fix for issue #1600, moved every node by the same fixed step (learningRate0.01 xlambda0.1), so in 100 iterations a low-degree node hardly moved and two unconnected 4-node cliques split on noise in 1 run of 20, while a step large enough for them overshot on high-degree nodes. A node's step is now its gradient divided bylambdax (out-degree + 1), andlearningRateis the fraction of that step it takes (default 0.5, stable for any value up to 1);lambdaweights the reported loss only;maxIterationsdefaults to 1000. Unconnected cliques of 3 to 30 nodes now split and converge on every seed. Labels, embeddings, loss and iteration counts differ from 2.x's on every graph.syncClusteringkeeps connected communities apart. 3.x before the fix for issue #1698 also pulled every node toward the origin at each step, scaled by its degree, and ran until the loss settled, so the communities it found a few iterations in blurred together and k-means split on degree instead: on planted partitions of 4 groups of 20 nodes (pIn 0.5, pOut 0.02) the mean purity was 0.68. Each node now moves part of the way to the mean of its out-neighbors' embeddings with no pull toward the origin, and a run also stops once the cluster assignment has held for 10 iterations, which counts asconverged. That purity is now 0.98, and runs end in tens of iterations rather than hundreds. Labels, embeddings, loss and iteration counts differ on every graph.grsbmkeeps a split only when it raises the whole partition's modularity. 2.x, and 3.x before the fix for issue #960, compared the two halves' modularity terms alone with the parent split's and kept a split that lowered that number by up to 0.01, so it kept splits that hurt the partition and refused ones that helped. A split is now kept when the modularity of the whole partition, every other cluster held as it is, goes up. Each cluster'smodularity, each split'sbisectionModularityandmodularityScoresare now that whole-partition modularity, andimprovementis how much the split raised it. Four planted groups of 20 (pIn 0.5, pOut 0.02) now come back as the four groups at modularity 0.646, where they came back as two groups of 40 at 0.438.- Flow and cuts.
maxFlowreports the net flow of two opposite edges on the edge it runs along, each within its capacity;minSTCutreports cut edges on graphs with numeric ids (2.x reported none);stoerWagneradds the weights of two opposite directed edges;kargerMinCutis seeded, so one graph gives one result, and on a graph of three or more components it puts every node on a side (2.x dropped the components after the first two); a source equal to the sink throws aRangeError, and a NaN weight throws. A node that is only the target of an edge is a node: flow reaches such a sink (2.xedmondsKarpon a Map-of-Maps returned no flow when the source or the sink was not a key). Cut sides list nodes in node order and cut edges by their source-side node, where 2.x followed the order its search met them. - Matching and isomorphism. The matchings join a left node to a right one whichever way the arc points. The greedy matching visits left nodes in node order, so it can differ in size. The maximum matching keeps the 2.x tie order -- left nodes in the order a breadth-first walk reaches them, neighbours in the order their edges were added -- so it picks the same pairs as 2.x on a graph whose edges were added in the same order; an isomorphism
edgeMatchis offered every pair of corresponding edges, self-loops included, and directed graphs are checked on in-arcs too. An isomorphism's mapping and a matching's pairs come as arrays indexed by node, so code that walks them meets the pairs in node order; 2.x returned a Map filled in the order its search found them. The 2.xfindAllMappingsoption is gone; with it set, 2.xisGraphIsomorphicanswered false for every pair. - k-core does not count a self-loop toward its node's core number, and refuses a directed graph.
- Delta PageRank.
DeltaPageRankandPriorityDeltaPageRankrun over a frozen snapshot:update()no longer reads a graph changed after construction.
Sampled closeness ​
closenessCentrality takes the two sampling options betweennessCentrality has: sources, a list of node indices to run from (a duplicate runs twice), or k, how many distinct sources to draw. The draw is the one betweenness makes, so the same (n, k) draws the same sources every time; with both, k must equal sources.length, and a bad value throws a RangeError. The result carries sourcesUsed, the number of sources run (nodeCount for the exact score).
A sampled score is each node's closeness from its distances TO the sampled sources (over in-arcs on a directed graph), with the same formula as the exact score and no extrapolation: 1 / score is the summed distance to the sources the node reaches. Multiply a plain score by k / n for the Eppstein-Wang estimate of the exact score; a harmonic score needs n / k instead, and a normalized one needs no factor. A sample of every node gives the exact scores. nodeClosenessCentrality takes no sampling options.
On the accelerator seam, AlgorithmAccelerator.closenessCentrality takes ClosenessAcceleratorOptions (weighted, and sources for a sampled run) and resolves to a ClosenessResultLike, which adds sourcesUsed. The dispatcher hands a sampled call to the accelerator only on an undirected graph, with the sources already drawn.