@graphty/algorithms 1.6.0 → 1.7.1

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.
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@graphty/algorithms",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
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",
7
7
  "type": "module",
8
8
  "exports": {
9
9
  ".": {
10
+ "types": "./dist/algorithms.d.ts",
10
11
  "import": "./dist/algorithms.js",
11
- "types": "./dist/algorithms.d.ts"
12
+ "default": "./dist/algorithms.js"
12
13
  }
13
14
  },
14
15
  "types": "dist/algorithms.d.ts",
@@ -56,10 +57,35 @@
56
57
  }
57
58
  },
58
59
  "devDependencies": {
60
+ "@chromatic-com/storybook": "^4.0.0",
61
+ "@eslint/js": "^9.29.0",
62
+ "@semantic-release/changelog": "^6.0.3",
63
+ "@semantic-release/git": "^10.0.1",
64
+ "@storybook/addon-docs": "^9.0.11",
65
+ "@storybook/html-vite": "^9.0.11",
66
+ "@storybook/test": "^8.6.14",
59
67
  "@types/benchmark": "^2.1.5",
60
68
  "benchmark": "^2.1.4",
69
+ "chromatic": "^11.0.0",
70
+ "eslint": "^9.29.0",
71
+ "eslint-plugin-simple-import-sort": "^12.1.1",
61
72
  "gh-pages": "^6.3.0",
62
- "ts-node": "^10.9.2"
73
+ "globals": "^15.12.0",
74
+ "happy-dom": "^18.0.1",
75
+ "knip": "^5.61.3",
76
+ "playwright": "^1.53.0",
77
+ "semantic-release": "^25.0.2",
78
+ "storybook": "^9.0.11",
79
+ "ts-node": "^10.9.2",
80
+ "tsx": "^4.20.3",
81
+ "typedoc": "^0.28.15",
82
+ "typedoc-plugin-markdown": "^4.9.0",
83
+ "typedoc-vitepress-theme": "^1.1.2",
84
+ "typescript": "^5.8.3",
85
+ "typescript-eslint": "^8.34.1",
86
+ "vite": "^7.0.5",
87
+ "vitepress": "^1.6.3",
88
+ "vitest": "^3.2.4"
63
89
  },
64
90
  "dependencies": {
65
91
  "pupt": "^1.3.2",
@@ -112,6 +138,8 @@
112
138
  "docs:dev": "vitepress dev docs",
113
139
  "docs:watch": "npm run docs:api && (npm run docs:api:watch & npm run docs:dev & wait)",
114
140
  "docs:build": "npm run docs:api && vitepress build docs",
115
- "docs:preview": "vitepress preview docs"
141
+ "docs:preview": "vitepress preview docs",
142
+ "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
+ "build-storybook": "storybook build"
116
144
  }
117
145
  }
@@ -39,7 +39,7 @@ export function girvanNewman(graph: Graph, options: GirvanNewmanOptions = {}): C
39
39
  while (Array.from(workingGraph.edges()).length > 0 && iterations < maxIterations) {
40
40
  iterations++;
41
41
  // Calculate edge betweenness centrality
42
- const edgeBetweenness = calculateEdgeBetweenness(workingGraph);
42
+ const { betweenness: edgeBetweenness, edgeEndpoints } = calculateEdgeBetweenness(workingGraph);
43
43
 
44
44
  if (edgeBetweenness.size === 0) {
45
45
  break;
@@ -51,9 +51,10 @@ export function girvanNewman(graph: Graph, options: GirvanNewmanOptions = {}): C
51
51
 
52
52
  for (const [edgeKey, centrality] of edgeBetweenness) {
53
53
  if (Math.abs(centrality - maxBetweenness) < 1e-10) {
54
- const [source, target] = edgeKey.split("|");
55
- if (source && target) {
56
- edgesToRemove.push({ source, target });
54
+ // Use original edge endpoints to preserve node ID types (number vs string)
55
+ const endpoints = edgeEndpoints.get(edgeKey);
56
+ if (endpoints) {
57
+ edgesToRemove.push(endpoints);
57
58
  }
58
59
  }
59
60
  }
@@ -91,21 +92,31 @@ export function girvanNewman(graph: Graph, options: GirvanNewmanOptions = {}): C
91
92
  return dendrogram;
92
93
  }
93
94
 
95
+ /**
96
+ * Edge betweenness result containing centrality values and original edge endpoints
97
+ */
98
+ interface EdgeBetweennessResult {
99
+ betweenness: Map<string, number>;
100
+ edgeEndpoints: Map<string, { source: NodeId; target: NodeId }>;
101
+ }
102
+
94
103
  /**
95
104
  * Calculate edge betweenness centrality for all edges in the graph
96
105
  *
97
106
  * Edge betweenness is the fraction of shortest paths that pass through the edge.
98
107
  * We adapt node betweenness centrality calculation to work with edges.
99
108
  * @param graph - The input graph to analyze
100
- * @returns Map of edge keys to their betweenness centrality values
109
+ * @returns Object with betweenness map and edge endpoints map (preserving original ID types)
101
110
  */
102
- function calculateEdgeBetweenness(graph: Graph): Map<string, number> {
111
+ function calculateEdgeBetweenness(graph: Graph): EdgeBetweennessResult {
103
112
  const edgeBetweenness = new Map<string, number>();
113
+ const edgeEndpoints = new Map<string, { source: NodeId; target: NodeId }>();
104
114
 
105
- // Initialize all edges with 0 betweenness
115
+ // Initialize all edges with 0 betweenness and store original endpoints
106
116
  for (const edge of graph.edges()) {
107
117
  const edgeKey = getEdgeKey(edge.source, edge.target);
108
118
  edgeBetweenness.set(edgeKey, 0);
119
+ edgeEndpoints.set(edgeKey, { source: edge.source, target: edge.target });
109
120
  }
110
121
 
111
122
  // For each node as source, calculate shortest paths and accumulate edge betweenness
@@ -182,7 +193,7 @@ function calculateEdgeBetweenness(graph: Graph): Map<string, number> {
182
193
  }
183
194
  }
184
195
 
185
- return edgeBetweenness;
196
+ return { betweenness: edgeBetweenness, edgeEndpoints };
186
197
  }
187
198
 
188
199
  /**
@@ -111,7 +111,7 @@ function buildTransitionMatrix(graph: Graph, nodeIds: NodeId[], selfLoops: boole
111
111
  // Fill adjacency values
112
112
  for (let i = 0; i < n; i++) {
113
113
  const nodeId = nodeIds[i];
114
- if (!nodeId) {
114
+ if (nodeId === undefined) {
115
115
  continue;
116
116
  }
117
117
 
@@ -398,11 +398,16 @@ function extractClusters(
398
398
  }
399
399
  }
400
400
 
401
- // Assign nodes to clusters based on columns
402
- let clusterIndex = 0;
401
+ // Assign nodes (columns) to clusters based on which attractor (row) they flow to
402
+ // After MCL convergence, matrix[i][j] > 0 means node j belongs to attractor i's cluster
403
+ const attractorToCommunity = new Map<number, number[]>();
404
+
403
405
  for (let j = 0; j < n; j++) {
404
- // Find nodes that belong to this cluster (column)
405
- const clusterNodes: number[] = [];
406
+ // Find the attractor for this column (node j)
407
+ // Look for the row with the highest non-zero value in column j
408
+ let maxVal = 0;
409
+ let attractorRow = -1;
410
+
406
411
  for (let i = 0; i < n; i++) {
407
412
  const matrixRow = matrix[i];
408
413
  if (!matrixRow) {
@@ -410,34 +415,43 @@ function extractClusters(
410
415
  }
411
416
 
412
417
  const val = matrixRow[j];
413
- if (val !== undefined && val > 0 && !nodeToCluster.has(i)) {
414
- clusterNodes.push(i);
415
- nodeToCluster.set(i, clusterIndex);
418
+ if (val !== undefined && val > maxVal) {
419
+ maxVal = val;
420
+ attractorRow = i;
416
421
  }
417
422
  }
418
423
 
419
- if (clusterNodes.length > 0) {
420
- communities.push(
421
- clusterNodes
422
- .map((idx) => {
423
- const nodeId = nodeIds[idx];
424
- return nodeId;
425
- })
426
- .filter((node): node is NodeId => node !== undefined),
427
- );
428
- clusterIndex++;
424
+ if (attractorRow >= 0) {
425
+ // Node j belongs to the cluster of attractor at row attractorRow
426
+ let community = attractorToCommunity.get(attractorRow);
427
+ if (!community) {
428
+ community = [];
429
+ attractorToCommunity.set(attractorRow, community);
430
+ }
431
+ community.push(j);
432
+ nodeToCluster.set(j, attractorRow);
429
433
  }
430
434
  }
431
435
 
432
- // Handle isolated nodes
433
- for (let i = 0; i < n; i++) {
434
- if (!nodeToCluster.has(i)) {
435
- const nodeId = nodeIds[i];
436
+ // Convert attractor communities to node ID arrays
437
+ for (const [, memberIndices] of attractorToCommunity) {
438
+ communities.push(
439
+ memberIndices
440
+ .map((idx) => {
441
+ const nodeId = nodeIds[idx];
442
+ return nodeId;
443
+ })
444
+ .filter((node): node is NodeId => node !== undefined),
445
+ );
446
+ }
447
+
448
+ // Handle isolated nodes (nodes with all-zero columns)
449
+ for (let j = 0; j < n; j++) {
450
+ if (!nodeToCluster.has(j)) {
451
+ const nodeId = nodeIds[j];
436
452
  if (nodeId !== undefined) {
437
453
  communities.push([nodeId]);
438
454
  }
439
-
440
- nodeToCluster.set(i, clusterIndex++);
441
455
  }
442
456
  }
443
457
 
@@ -1,6 +1,6 @@
1
1
  import type { Graph } from "../core/graph.js";
2
2
  import type { NodeId } from "../types/index.js";
3
- import { euclideanDistance } from "../utils/math-utilities.js";
3
+ import { euclideanDistance, SeededRandom } from "../utils/math-utilities.js";
4
4
 
5
5
  /**
6
6
  * Spectral Clustering implementation
@@ -17,6 +17,7 @@ export interface SpectralClusteringOptions {
17
17
  laplacianType?: "unnormalized" | "normalized" | "randomWalk"; // Type of Laplacian
18
18
  maxIterations?: number; // Max iterations for k-means (default: 100)
19
19
  tolerance?: number; // Convergence tolerance (default: 1e-4)
20
+ seed?: number; // Random seed for reproducible results
20
21
  }
21
22
 
22
23
  export interface SpectralClusteringResult {
@@ -41,7 +42,10 @@ export interface SpectralClusteringResult {
41
42
  * @returns Spectral clustering result with communities and cluster assignments
42
43
  */
43
44
  export function spectralClustering(graph: Graph, options: SpectralClusteringOptions): SpectralClusteringResult {
44
- const { k, laplacianType = "normalized", maxIterations = 100, tolerance = 1e-4 } = options;
45
+ const { k, laplacianType = "normalized", maxIterations = 100, tolerance = 1e-4, seed } = options;
46
+
47
+ // Create random function - use seeded PRNG if seed provided, otherwise Math.random
48
+ const random = seed !== undefined ? SeededRandom.createGenerator(seed) : Math.random;
45
49
 
46
50
  // Input validation
47
51
  if (k < 1 || !Number.isInteger(k)) {
@@ -67,7 +71,7 @@ export function spectralClustering(graph: Graph, options: SpectralClusteringOpti
67
71
  const laplacianMatrix = buildLaplacianMatrix(adjacencyMatrix, laplacianType);
68
72
 
69
73
  // Find k smallest eigenvectors
70
- const eigenResult = findSmallestEigenvectors(laplacianMatrix, k);
74
+ const eigenResult = findSmallestEigenvectors(laplacianMatrix, k, random);
71
75
 
72
76
  // Perform k-means clustering on the eigenvectors
73
77
  // For spectral clustering, we need to transpose the eigenvector matrix
@@ -89,7 +93,7 @@ export function spectralClustering(graph: Graph, options: SpectralClusteringOpti
89
93
  normalizeRows(dataPoints);
90
94
  }
91
95
 
92
- const kmeans = kMeansClustering(dataPoints, k, maxIterations, tolerance);
96
+ const kmeans = kMeansClustering(dataPoints, k, maxIterations, tolerance, random);
93
97
 
94
98
  // Build communities
95
99
  const communities: NodeId[][] = Array.from({ length: k }, () => []);
@@ -293,11 +297,13 @@ function buildLaplacianMatrix(adjacency: number[][], type: string): number[][] {
293
297
  * This is a simplified implementation - in practice, you'd use LAPACK or similar
294
298
  * @param matrix - The Laplacian matrix for eigendecomposition
295
299
  * @param k - Number of smallest eigenvectors to find
300
+ * @param random - Random number generator function
296
301
  * @returns Object containing eigenvalues and corresponding eigenvectors
297
302
  */
298
303
  function findSmallestEigenvectors(
299
304
  matrix: number[][],
300
305
  k: number,
306
+ random: () => number,
301
307
  ): {
302
308
  eigenvalues: number[];
303
309
  eigenvectors: number[][];
@@ -318,7 +324,7 @@ function findSmallestEigenvectors(
318
324
  // For spectral clustering, we need proper eigenvectors
319
325
  // Special handling for small k values which are common in clustering
320
326
  if (k <= 3 && n > k) {
321
- return computeSmallestEigenvectorsSimple(matrix, k, n);
327
+ return computeSmallestEigenvectorsSimple(matrix, k, n, random);
322
328
  }
323
329
 
324
330
  // For larger k, use power iteration
@@ -330,7 +336,7 @@ function findSmallestEigenvectors(
330
336
  // Initialize random vector
331
337
  let vector = Array(n)
332
338
  .fill(0)
333
- .map(() => Math.random() - 0.5);
339
+ .map(() => random() - 0.5);
334
340
 
335
341
  // Normalize initial vector
336
342
  const initNorm = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0));
@@ -447,6 +453,7 @@ function normalizeRows(matrix: number[][]): void {
447
453
  * @param k - Number of clusters to form
448
454
  * @param maxIterations - Maximum number of iterations
449
455
  * @param tolerance - Convergence tolerance for centroid movement
456
+ * @param random - Random number generator function
450
457
  * @returns Object containing cluster assignments and final centroids
451
458
  */
452
459
  function kMeansClustering(
@@ -454,6 +461,7 @@ function kMeansClustering(
454
461
  k: number,
455
462
  maxIterations: number,
456
463
  tolerance = 1e-4,
464
+ random: () => number = Math.random,
457
465
  ): { assignments: number[]; centroids: number[][] } {
458
466
  const n = data.length;
459
467
  const d = data[0]?.length ?? 0;
@@ -476,7 +484,7 @@ function kMeansClustering(
476
484
  const selectedIndices = new Set<number>();
477
485
 
478
486
  while (centroids.length < k && selectedIndices.size < n) {
479
- const idx = Math.floor(Math.random() * n);
487
+ const idx = Math.floor(random() * n);
480
488
  if (!selectedIndices.has(idx) && data[idx]) {
481
489
  selectedIndices.add(idx);
482
490
  centroids.push([...data[idx]]);
@@ -487,7 +495,7 @@ function kMeansClustering(
487
495
  while (centroids.length < k) {
488
496
  const centroid = Array(d).fill(0) as number[];
489
497
  for (let j = 0; j < d; j++) {
490
- centroid[j] = Math.random() - 0.5;
498
+ centroid[j] = random() - 0.5;
491
499
  }
492
500
  centroids.push(centroid);
493
501
  }
@@ -611,12 +619,14 @@ function kMeansClustering(
611
619
  * @param matrix - The Laplacian matrix
612
620
  * @param k - Number of eigenvectors to compute (1-3)
613
621
  * @param n - Size of the matrix
622
+ * @param random - Random number generator function
614
623
  * @returns Object containing eigenvalues and eigenvectors
615
624
  */
616
625
  function computeSmallestEigenvectorsSimple(
617
626
  matrix: number[][],
618
627
  k: number,
619
628
  n: number,
629
+ random: () => number,
620
630
  ): {
621
631
  eigenvalues: number[];
622
632
  eigenvectors: number[][];
@@ -635,7 +645,7 @@ function computeSmallestEigenvectorsSimple(
635
645
  const maxEig = 2; // For normalized Laplacian, max eigenvalue <= 2
636
646
  let vector = Array(n)
637
647
  .fill(0)
638
- .map(() => Math.random() - 0.5);
648
+ .map(() => random() - 0.5);
639
649
 
640
650
  // Make orthogonal to first eigenvector
641
651
  const dot1 = vector.reduce((sum, val) => sum + val / Math.sqrt(n), 0);
@@ -681,7 +691,7 @@ function computeSmallestEigenvectorsSimple(
681
691
  if (k >= 3) {
682
692
  let vector = Array(n)
683
693
  .fill(0)
684
- .map(() => Math.random() - 0.5);
694
+ .map(() => random() - 0.5);
685
695
 
686
696
  // Orthogonalize against previous eigenvectors
687
697
  for (const prev of eigenvectors) {