@graphty/algorithms 1.8.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -4
  2. package/dist/algorithms.js +518 -525
  3. package/dist/algorithms.js.map +1 -1
  4. package/dist/algorithms.standalone.js +519 -526
  5. package/dist/algorithms.standalone.js.map +1 -1
  6. package/dist/src/algorithms/centrality/eigenvector.d.ts +13 -2
  7. package/dist/src/algorithms/centrality/eigenvector.d.ts.map +1 -1
  8. package/dist/src/algorithms/centrality/eigenvector.js +96 -73
  9. package/dist/src/algorithms/centrality/eigenvector.js.map +1 -1
  10. package/dist/src/algorithms/community/girvan-newman.js +35 -15
  11. package/dist/src/algorithms/community/girvan-newman.js.map +1 -1
  12. package/dist/src/algorithms/community/leiden.d.ts.map +1 -1
  13. package/dist/src/algorithms/community/leiden.js +214 -222
  14. package/dist/src/algorithms/community/leiden.js.map +1 -1
  15. package/dist/src/algorithms/community/louvain-optimized.d.ts +28 -72
  16. package/dist/src/algorithms/community/louvain-optimized.d.ts.map +1 -1
  17. package/dist/src/algorithms/community/louvain-optimized.js +249 -262
  18. package/dist/src/algorithms/community/louvain-optimized.js.map +1 -1
  19. package/dist/src/algorithms/community/modularity-utils.d.ts +15 -7
  20. package/dist/src/algorithms/community/modularity-utils.d.ts.map +1 -1
  21. package/dist/src/algorithms/community/modularity-utils.js +38 -38
  22. package/dist/src/algorithms/community/modularity-utils.js.map +1 -1
  23. package/dist/src/algorithms/shortest-path/bellman-ford.d.ts.map +1 -1
  24. package/dist/src/algorithms/shortest-path/bellman-ford.js +35 -8
  25. package/dist/src/algorithms/shortest-path/bellman-ford.js.map +1 -1
  26. package/dist/src/errors.d.ts +21 -0
  27. package/dist/src/errors.d.ts.map +1 -0
  28. package/dist/src/errors.js +23 -0
  29. package/dist/src/errors.js.map +1 -0
  30. package/dist/src/index.d.ts +1 -0
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/index.js +2 -0
  33. package/dist/src/index.js.map +1 -1
  34. package/dist/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +7 -7
  36. package/src/algorithms/centrality/eigenvector.ts +103 -84
  37. package/src/algorithms/community/girvan-newman.ts +38 -17
  38. package/src/algorithms/community/leiden.ts +270 -275
  39. package/src/algorithms/community/louvain-optimized.ts +303 -313
  40. package/src/algorithms/community/modularity-utils.ts +42 -45
  41. package/src/algorithms/shortest-path/bellman-ford.ts +46 -8
  42. package/src/errors.ts +26 -0
  43. package/src/index.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/algorithms",
3
- "version": "1.8.1",
3
+ "version": "2.0.0",
4
4
  "description": "Graph algorithms library for browser environments implemented in TypeScript",
5
5
  "author": "Adam Powers <apowers@ato.ms>",
6
6
  "main": "dist/algorithms.js",
@@ -107,7 +107,7 @@
107
107
  "coverage:shard:default": "COVERAGE_DIR=.coverage-parts/default vitest run --project=default --coverage",
108
108
  "coverage:shard:browser": "COVERAGE_DIR=.coverage-parts/browser vitest run --project=browser --coverage",
109
109
  "coverage:fast": "vitest run --project=default --coverage",
110
- "coverage:preview": "npx serve coverage -p 9051",
110
+ "coverage:preview": "npx serve coverage -p ${PORT:?start it through servherd, which sets PORT}",
111
111
  "test:browser": "vitest --project=browser",
112
112
  "test:all": "vitest run --project=default --project=browser",
113
113
  "test:performance": "tsx test/helpers/run-performance-regression.ts",
@@ -130,18 +130,18 @@
130
130
  "dev": "tsc --watch",
131
131
  "examples": "node examples/run-all-examples.js",
132
132
  "examples:run": "node examples/run-all-examples.js",
133
- "examples:html": "npm run build:bundle && vite",
134
- "serve": "npm run build:bundle && vite",
133
+ "examples:html": "npm run build:bundle && vite --port ${PORT:?start it through servherd, which sets PORT} --strictPort",
134
+ "serve": "npm run build:bundle && vite --port ${PORT:?start it through servherd, which sets PORT} --strictPort",
135
135
  "commit": "cz",
136
136
  "ready:commit": "npm run build && npm run lint && npm run test:all",
137
137
  "watch": "tsc --watch",
138
138
  "docs:api": "typedoc && node scripts/sanitize-api-docs.js",
139
139
  "docs:api:watch": "typedoc --watch",
140
- "docs:dev": "vitepress dev docs",
140
+ "docs:dev": "vitepress dev docs --port ${PORT:?start it through servherd, which sets PORT} --strictPort",
141
141
  "docs:watch": "npm run docs:api && (npm run docs:api:watch & npm run docs:dev & wait)",
142
142
  "docs:build": "npm run docs:api && vitepress build docs",
143
- "docs:preview": "vitepress preview docs",
144
- "storybook": ". ../.env 2>/dev/null; storybook dev -p ${PORT:-6006} --host ${HOST:-localhost} ${HTTPS_CERT_PATH:+--https --ssl-cert $HTTPS_CERT_PATH --ssl-key $HTTPS_KEY_PATH} --no-open",
143
+ "docs:preview": "vitepress preview docs --port ${PORT:?start it through servherd, which sets PORT}",
144
+ "storybook": "storybook dev -p ${PORT:?start it through servherd, which sets PORT} --host ${HOST:-localhost} ${HTTPS_CERT_PATH:+--https --ssl-cert $HTTPS_CERT_PATH --ssl-key $HTTPS_KEY_PATH} --no-open",
145
145
  "build-storybook": "storybook build"
146
146
  }
147
147
  }
@@ -1,4 +1,5 @@
1
1
  import type { Graph } from "../../core/graph.js";
2
+ import { ConvergenceError } from "../../errors.js";
2
3
  import type { CentralityOptions, CentralityResult } from "../../types/index.js";
3
4
 
4
5
  /**
@@ -9,137 +10,155 @@ import type { CentralityOptions, CentralityResult } from "../../types/index.js";
9
10
  * that themselves have high eigenvector centrality.
10
11
  *
11
12
  * Time complexity: O(V + E) per iteration
12
- * Space complexity: O(V)
13
+ * Space complexity: O(V + E)
13
14
  */
14
15
 
15
16
  export interface EigenvectorCentralityOptions extends CentralityOptions {
16
17
  maxIterations?: number; // Maximum iterations (default: 100)
17
- tolerance?: number; // Convergence tolerance (default: 1e-6)
18
+ tolerance?: number; // Convergence tolerance per node (default: 1e-6)
18
19
  startVector?: Map<string, number>; // Initial vector (optional)
19
20
  }
20
21
 
21
22
  /**
22
23
  * Calculate eigenvector centrality for all nodes in the graph.
23
- * Uses the power iteration method to find the dominant eigenvector.
24
+ *
25
+ * Iterates x <- (A + I) x, as networkx does. Shifting by the identity raises every eigenvalue by
26
+ * one and leaves the eigenvectors alone, so the largest eigenvalue becomes the only one of largest
27
+ * magnitude -- even on a bipartite graph, whose spectrum holds both +lambda and -lambda and makes
28
+ * unshifted power iteration oscillate forever. The run stops when the L1 change summed over all
29
+ * nodes falls below `n * tolerance`, networkx's test.
30
+ *
31
+ * A node is fed by `graph.neighbors`, i.e. by its out-neighbours on a directed graph. When the
32
+ * graph has no cycle along that relation (no edges, or a directed acyclic graph) the adjacency
33
+ * matrix is nilpotent, its only eigenvalue is 0, and every score is exactly 0.
24
34
  * @param graph - The graph to compute eigenvector centrality on
25
35
  * @param options - Configuration options for the computation
26
36
  * @returns Object mapping node IDs to their eigenvector centrality scores
37
+ * @throws {ConvergenceError} When `maxIterations` passes do not meet `tolerance`, as networkx
38
+ * raises `PowerIterationFailedConvergence`. Raise `maxIterations` or `tolerance` and call again.
27
39
  */
28
40
  export function eigenvectorCentrality(graph: Graph, options: EigenvectorCentralityOptions = {}): CentralityResult {
29
41
  const { maxIterations = 100, tolerance = 1e-6, normalized = true, startVector } = options;
30
42
 
43
+ const nodeIds = Array.from(graph.nodes(), (node) => node.id);
44
+ const keys = nodeIds.map((id) => id.toString());
45
+ const n = keys.length;
31
46
  const centrality: CentralityResult = {};
32
- const nodes = Array.from(graph.nodes());
33
- const nodeIds = nodes.map((node) => node.id);
34
47
 
35
- if (nodeIds.length === 0) {
48
+ if (n === 0) {
36
49
  return centrality;
37
50
  }
38
51
 
39
- // Initialize the eigenvector
40
- let currentVector = new Map<string, number>();
41
- let previousVector = new Map<string, number>();
52
+ const index = new Map(keys.map((key, i) => [key, i]));
53
+ const adjacency = nodeIds.map((id) => Array.from(graph.neighbors(id), (m) => index.get(m.toString()) ?? 0));
42
54
 
43
- if (startVector) {
44
- // Use provided start vector
45
- for (const nodeId of nodeIds) {
46
- const key = nodeId.toString();
47
- currentVector.set(key, startVector.get(key) ?? 1.0 / Math.sqrt(nodeIds.length));
48
- }
49
- } else {
50
- // Initialize with uniform distribution
51
- const initialValue = 1.0 / Math.sqrt(nodeIds.length);
52
- for (const nodeId of nodeIds) {
53
- currentVector.set(nodeId.toString(), initialValue);
55
+ if (isNilpotent(adjacency)) {
56
+ for (const key of keys) {
57
+ centrality[key] = 0;
54
58
  }
59
+ return centrality;
55
60
  }
56
61
 
57
- // Power iteration
58
- for (let iteration = 0; iteration < maxIterations; iteration++) {
59
- previousVector = new Map(currentVector);
60
- currentVector = new Map();
61
-
62
- // Update each node's centrality based on neighbors
63
- for (const nodeId of nodeIds) {
64
- let sum = 0;
65
- const neighbors = Array.from(graph.neighbors(nodeId));
66
-
67
- for (const neighbor of neighbors) {
68
- const neighborKey = neighbor.toString();
69
- const prevValue = previousVector.get(neighborKey);
70
- sum += prevValue ?? 0;
71
- }
72
-
73
- currentVector.set(nodeId.toString(), sum);
74
- }
75
-
76
- // Normalize the vector
77
- let norm = 0;
78
- for (const value of Array.from(currentVector.values())) {
79
- norm += value * value;
80
- }
81
- norm = Math.sqrt(norm);
82
-
83
- if (norm === 0) {
84
- // Graph has no edges or is disconnected
85
- for (const nodeId of nodeIds) {
86
- centrality[nodeId.toString()] = 0;
62
+ // A node with nothing feeding it scores exactly 0 once the largest eigenvalue is positive,
63
+ // so it starts there and (A + I) keeps it there.
64
+ let x = new Float64Array(n);
65
+ for (let i = 0; i < n; i++) {
66
+ x[i] = adjacency[i]?.length ? (startVector?.get(keys[i] ?? "") ?? 1) : 0;
67
+ }
68
+ scaleToUnitLength(x);
69
+
70
+ let next = new Float64Array(n);
71
+ let converged = false;
72
+ for (let iteration = 0; iteration < maxIterations && !converged; iteration++) {
73
+ for (let i = 0; i < n; i++) {
74
+ let sum = x[i] ?? 0;
75
+ for (const j of adjacency[i] ?? []) {
76
+ sum += x[j] ?? 0;
87
77
  }
88
- return centrality;
89
- }
90
-
91
- // Normalize the vector
92
- for (const [nodeId, value] of Array.from(currentVector)) {
93
- currentVector.set(nodeId, value / norm);
78
+ next[i] = sum;
94
79
  }
80
+ scaleToUnitLength(next);
95
81
 
96
- // Check for convergence
97
- let maxDiff = 0;
98
- for (const [nodeId, value] of Array.from(currentVector)) {
99
- const prevValue = previousVector.get(nodeId) ?? 0;
100
- const diff = Math.abs(value - prevValue);
101
- maxDiff = Math.max(maxDiff, diff);
102
- }
103
-
104
- if (maxDiff < tolerance) {
105
- break;
82
+ let change = 0;
83
+ for (let i = 0; i < n; i++) {
84
+ change += Math.abs((next[i] ?? 0) - (x[i] ?? 0));
106
85
  }
86
+ [x, next] = [next, x];
87
+ converged = change < n * tolerance;
88
+ }
89
+ if (!converged) {
90
+ throw new ConvergenceError("eigenvectorCentrality", maxIterations, tolerance);
107
91
  }
108
92
 
109
- // Prepare results
110
- for (const [nodeId, value] of Array.from(currentVector)) {
111
- centrality[nodeId] = value;
93
+ for (let i = 0; i < n; i++) {
94
+ centrality[keys[i] ?? ""] = x[i] ?? 0;
112
95
  }
113
96
 
114
97
  // Additional normalization if requested (normalize to [0,1] range)
115
98
  if (normalized) {
116
99
  let maxValue = 0;
117
100
  let minValue = Number.POSITIVE_INFINITY;
118
-
119
- for (const value of Object.values(centrality)) {
101
+ for (const value of x) {
120
102
  maxValue = Math.max(maxValue, value);
121
103
  minValue = Math.min(minValue, value);
122
104
  }
123
-
124
105
  const range = maxValue - minValue;
125
- if (range > 0) {
126
- for (const nodeId of Object.keys(centrality)) {
127
- const centralityValue = centrality[nodeId];
128
- if (centralityValue !== undefined) {
129
- centrality[nodeId] = (centralityValue - minValue) / range;
130
- }
131
- }
132
- } else {
133
- // All values are the same, set to 1
134
- for (const nodeId of Object.keys(centrality)) {
135
- centrality[nodeId] = maxValue > 0 ? 1 : 0;
136
- }
106
+ // All scores equal: 1 each, or 0 each when there is nothing to score.
107
+ const flat = maxValue > 0 ? 1 : 0;
108
+ for (const key of keys) {
109
+ centrality[key] = range > 0 ? ((centrality[key] ?? 0) - minValue) / range : flat;
137
110
  }
138
111
  }
139
112
 
140
113
  return centrality;
141
114
  }
142
115
 
116
+ /**
117
+ * Whether the relation has no cycle (Kahn's algorithm), which makes its adjacency matrix nilpotent.
118
+ * @param adjacency - Each node's neighbour indices
119
+ * @returns True when every node can be peeled off in topological order
120
+ */
121
+ function isNilpotent(adjacency: number[][]): boolean {
122
+ const inDegree = new Int32Array(adjacency.length);
123
+ for (const targets of adjacency) {
124
+ for (const j of targets) {
125
+ inDegree[j] = (inDegree[j] ?? 0) + 1;
126
+ }
127
+ }
128
+ const queue: number[] = [];
129
+ inDegree.forEach((degree, i) => {
130
+ if (degree === 0) {
131
+ queue.push(i);
132
+ }
133
+ });
134
+ for (let head = 0; head < queue.length; head++) {
135
+ for (const j of adjacency[queue[head] ?? 0] ?? []) {
136
+ inDegree[j] = (inDegree[j] ?? 0) - 1;
137
+ if (inDegree[j] === 0) {
138
+ queue.push(j);
139
+ }
140
+ }
141
+ }
142
+ return queue.length === adjacency.length;
143
+ }
144
+
145
+ /**
146
+ * Scale a vector in place to unit Euclidean length (a zero vector is left alone).
147
+ * @param v - The vector to scale
148
+ */
149
+ function scaleToUnitLength(v: Float64Array): void {
150
+ let norm = 0;
151
+ for (const value of v) {
152
+ norm += value * value;
153
+ }
154
+ norm = Math.sqrt(norm);
155
+ if (norm > 0) {
156
+ for (let i = 0; i < v.length; i++) {
157
+ v[i] = (v[i] ?? 0) / norm;
158
+ }
159
+ }
160
+ }
161
+
143
162
  /**
144
163
  * Calculate eigenvector centrality for a specific node.
145
164
  * @param graph - The graph to compute eigenvector centrality on
@@ -297,7 +297,23 @@ function getEdgeKey(source: NodeId, target: NodeId): string {
297
297
  }
298
298
 
299
299
  /**
300
- * Calculate modularity for a given community structure - optimized version
300
+ * Calculate modularity for a given community structure
301
+ *
302
+ * Newman's Q, written per community:
303
+ *
304
+ * Q = sum over communities c of [ w_in(c) / m - ( K_c / (2m) )^2 ]
305
+ *
306
+ * where m is the total edge weight with each undirected edge counted once, w_in(c) is the summed
307
+ * weight of the edges with both endpoints inside c, and K_c is the summed weighted degree of c's
308
+ * nodes.
309
+ *
310
+ * WHY NOT A SUM OVER EDGES. The textbook double sum runs over every ordered PAIR of nodes inside
311
+ * a community, not only over the pairs an edge happens to join. Collecting the null-model term
312
+ * only where an edge exists leaves the penalty far too small, and the shortfall grows with
313
+ * community size -- so the uncut whole graph, whose modularity is 0 by definition, outscores
314
+ * every real cut and a caller reading the dendrogram the standard way is handed one community
315
+ * containing everything. The per-community form above counts every pair exactly once and still
316
+ * costs one pass over the edges rather than a pass over every pair.
301
317
  * @param graph - The original graph
302
318
  * @param communityMap - Map from node IDs to community indices
303
319
  * @returns The modularity score of the partition
@@ -308,32 +324,37 @@ function calculateModularity(graph: Graph, communityMap: Map<NodeId, number>): n
308
324
  return 0;
309
325
  }
310
326
 
311
- let modularity = 0;
312
- const degrees = new Map<NodeId, number>();
313
-
314
- // Pre-calculate all degrees
327
+ // Summed weighted degree of each community's nodes.
328
+ const degreeSum = new Map<number, number>();
315
329
  for (const node of graph.nodes()) {
316
- degrees.set(node.id, getNodeDegree(graph, node.id));
330
+ const community = communityMap.get(node.id);
331
+ if (community === undefined) {
332
+ continue;
333
+ }
334
+
335
+ degreeSum.set(community, (degreeSum.get(community) ?? 0) + getNodeDegree(graph, node.id));
317
336
  }
318
337
 
319
- // Only iterate over existing edges
338
+ // Summed weight of the edges that stay inside a community.
339
+ const internalWeight = new Map<number, number>();
320
340
  for (const edge of graph.edges()) {
321
341
  const communityI = communityMap.get(edge.source);
322
342
  const communityJ = communityMap.get(edge.target);
323
343
 
324
- if (communityI === communityJ) {
325
- const edgeWeight = edge.weight ?? 1;
326
- const degreeI = degrees.get(edge.source);
327
- const degreeJ = degrees.get(edge.target);
328
- if (degreeI === undefined || degreeJ === undefined) {
329
- continue;
330
- }
331
-
332
- modularity += edgeWeight - (degreeI * degreeJ) / (2 * totalEdgeWeight);
344
+ if (communityI === undefined || communityI !== communityJ) {
345
+ continue;
333
346
  }
347
+
348
+ internalWeight.set(communityI, (internalWeight.get(communityI) ?? 0) + (edge.weight ?? 1));
349
+ }
350
+
351
+ let modularity = 0;
352
+ for (const [community, degrees] of degreeSum) {
353
+ const share = degrees / (2 * totalEdgeWeight);
354
+ modularity += (internalWeight.get(community) ?? 0) / totalEdgeWeight - share * share;
334
355
  }
335
356
 
336
- return modularity / (2 * totalEdgeWeight);
357
+ return modularity;
337
358
  }
338
359
 
339
360
  /**