@graphty/algorithms 1.1.0 → 1.2.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.
@@ -0,0 +1,506 @@
1
+ import type {Graph} from "../core/graph.js";
2
+ import type {NodeId} from "../types/index.js";
3
+
4
+ /**
5
+ * Configuration options for the TeraHAC (Hierarchical Agglomerative Clustering) algorithm
6
+ */
7
+ export interface TeraHACConfig {
8
+ /** Linkage criterion: 'single', 'complete', 'average', 'ward' */
9
+ linkage?: "single" | "complete" | "average" | "ward";
10
+ /** Number of clusters to stop at (optional) */
11
+ numClusters?: number;
12
+ /** Distance threshold to stop clustering */
13
+ distanceThreshold?: number;
14
+ /** Maximum number of nodes to process efficiently */
15
+ maxNodes?: number;
16
+ /** Use graph structure for distance calculation */
17
+ useGraphDistance?: boolean;
18
+ }
19
+
20
+ /**
21
+ * Represents a node in the dendrogram/cluster hierarchy
22
+ */
23
+ export interface ClusterNode {
24
+ /** Unique identifier for this cluster */
25
+ id: string;
26
+ /** Node IDs in this cluster */
27
+ members: Set<NodeId>;
28
+ /** Left child cluster (if internal node) */
29
+ left?: ClusterNode;
30
+ /** Right child cluster (if internal node) */
31
+ right?: ClusterNode;
32
+ /** Distance at which this cluster was formed */
33
+ distance: number;
34
+ /** Size of the cluster */
35
+ size: number;
36
+ }
37
+
38
+ /**
39
+ * Result of the TeraHAC clustering algorithm
40
+ */
41
+ export interface TeraHACResult {
42
+ /** Root of the dendrogram */
43
+ dendrogram: ClusterNode;
44
+ /** Flat clustering at specified level */
45
+ clusters: Map<NodeId, number>;
46
+ /** All merge distances in order */
47
+ distances: number[];
48
+ /** Number of clusters in final result */
49
+ numClusters: number;
50
+ }
51
+
52
+ /**
53
+ * TeraHAC - Hierarchical Agglomerative Clustering for Large Graphs
54
+ *
55
+ * This algorithm performs hierarchical clustering on graphs by iteratively
56
+ * merging the closest clusters. Optimized for scalability to handle large
57
+ * graphs efficiently.
58
+ *
59
+ * Based on: "Scaling Hierarchical Agglomerative Clustering to Trillion-Edge Graphs"
60
+ * Google Research 2024
61
+ *
62
+ * @param graph - Input graph to cluster
63
+ * @param config - Configuration options
64
+ * @returns Hierarchical clustering result
65
+ */
66
+ export function teraHAC(graph: Graph, config: TeraHACConfig = {}): TeraHACResult {
67
+ const {
68
+ linkage = "average",
69
+ numClusters,
70
+ distanceThreshold,
71
+ maxNodes = 10000,
72
+ useGraphDistance = true,
73
+ } = config;
74
+
75
+ const nodes = Array.from(graph.nodes());
76
+ const nodeCount = nodes.length;
77
+
78
+ if (nodeCount === 0) {
79
+ throw new Error("Cannot cluster empty graph");
80
+ }
81
+
82
+ if (nodeCount > maxNodes) {
83
+ console.warn(`Graph has ${String(nodeCount)} nodes, which exceeds maxNodes (${String(maxNodes)}). Performance may be degraded.`);
84
+ }
85
+
86
+ // Initialize each node as its own cluster
87
+ const clusters = new Map<string, ClusterNode>();
88
+ let nextClusterId = nodeCount;
89
+
90
+ for (let i = 0; i < nodeCount; i++) {
91
+ const node = nodes[i];
92
+ if (!node) {
93
+ continue;
94
+ }
95
+
96
+ const clusterId = i.toString();
97
+ clusters.set(clusterId, {
98
+ id: clusterId,
99
+ members: new Set([node.id]),
100
+ distance: 0,
101
+ size: 1,
102
+ });
103
+ }
104
+
105
+ // Calculate initial distance matrix
106
+ const distanceMatrix = calculateDistanceMatrix(graph, nodes, useGraphDistance);
107
+ const mergeDistances: number[] = [];
108
+
109
+ // Priority queue for efficient nearest neighbor finding
110
+ const mergeCandidates = initializeMergeCandidates(clusters, distanceMatrix);
111
+
112
+ let dendrogram: ClusterNode | undefined;
113
+
114
+ // Perform agglomerative clustering
115
+ while (clusters.size > 1) {
116
+ // Find closest pair of clusters
117
+ const {cluster1Id, cluster2Id, distance} = findClosestPair(mergeCandidates);
118
+
119
+ // Check stopping criteria
120
+ if (numClusters && clusters.size <= numClusters) {
121
+ break;
122
+ }
123
+
124
+ if (distanceThreshold && distance > distanceThreshold) {
125
+ break;
126
+ }
127
+
128
+ // Get the two clusters to merge
129
+ const cluster1 = clusters.get(cluster1Id);
130
+ const cluster2 = clusters.get(cluster2Id);
131
+ if (!cluster1 || !cluster2) {
132
+ continue;
133
+ }
134
+
135
+ // Create new merged cluster
136
+ const newClusterId = (nextClusterId++).toString();
137
+ const mergedMembers = new Set([... cluster1.members, ... cluster2.members]);
138
+
139
+ const newCluster: ClusterNode = {
140
+ id: newClusterId,
141
+ members: mergedMembers,
142
+ left: cluster1,
143
+ right: cluster2,
144
+ distance,
145
+ size: cluster1.size + cluster2.size,
146
+ };
147
+
148
+ // Remove old clusters
149
+ clusters.delete(cluster1Id);
150
+ clusters.delete(cluster2Id);
151
+
152
+ // Add new cluster
153
+ clusters.set(newClusterId, newCluster);
154
+ mergeDistances.push(distance);
155
+
156
+ // Update merge candidates
157
+ updateMergeCandidates(mergeCandidates, cluster1Id, cluster2Id, newClusterId, clusters, distanceMatrix, linkage);
158
+
159
+ dendrogram = newCluster;
160
+ }
161
+
162
+ // If we have multiple remaining clusters, create a virtual root
163
+ if (clusters.size > 1) {
164
+ const remainingClusters = Array.from(clusters.values());
165
+ let root = remainingClusters[0];
166
+ if (!root) {
167
+ dendrogram = undefined;
168
+ } else {
169
+ for (let i = 1; i < remainingClusters.length; i++) {
170
+ const currentCluster = remainingClusters[i];
171
+ if (!currentCluster) {
172
+ continue;
173
+ }
174
+
175
+ const newRoot: ClusterNode = {
176
+ id: (nextClusterId++).toString(),
177
+ members: new Set([... root.members, ... currentCluster.members]),
178
+ left: root,
179
+ right: currentCluster,
180
+ distance: Infinity,
181
+ size: root.size + currentCluster.size,
182
+ };
183
+ root = newRoot;
184
+ }
185
+ dendrogram = root;
186
+ }
187
+
188
+ dendrogram = root;
189
+ }
190
+
191
+ dendrogram ??= Array.from(clusters.values())[0];
192
+
193
+ // Extract flat clustering
194
+ const finalNumClusters = numClusters ?? clusters.size;
195
+ const flatClusters = dendrogram ? extractFlatClustering(dendrogram, finalNumClusters) : new Map<NodeId, number>();
196
+
197
+ if (!dendrogram) {
198
+ throw new Error("Failed to create dendrogram");
199
+ }
200
+
201
+ return {
202
+ dendrogram,
203
+ clusters: flatClusters,
204
+ distances: mergeDistances,
205
+ numClusters: finalNumClusters,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Calculate distance matrix between all pairs of nodes
211
+ */
212
+ function calculateDistanceMatrix(graph: Graph, nodes: {id: NodeId}[], useGraphDistance: boolean): number[][] {
213
+ const n = nodes.length;
214
+ const matrix: number[][] = Array.from({length: n}, () => new Array(n).fill(Infinity) as number[]);
215
+
216
+ if (useGraphDistance) {
217
+ // Use graph-based distances (shortest path)
218
+ for (let i = 0; i < n; i++) {
219
+ const node = nodes[i];
220
+ if (!node) {
221
+ continue;
222
+ }
223
+
224
+ const distances = bfsShortestPaths(graph, node.id);
225
+ for (let j = 0; j < n; j++) {
226
+ if (i !== j) {
227
+ const targetNode = nodes[j];
228
+ if (targetNode && i < matrix.length && j < n) {
229
+ const row = matrix[i];
230
+ if (row && j < row.length) {
231
+ const distance = distances.get(targetNode.id);
232
+ row[j] = distance ?? Infinity;
233
+ }
234
+ }
235
+ } else if (i < matrix.length) {
236
+ const row = matrix[i];
237
+ if (row && j < row.length) {
238
+ row[j] = 0;
239
+ }
240
+ }
241
+ }
242
+ }
243
+ } else {
244
+ // Use simple edge-based distances
245
+ for (let i = 0; i < n; i++) {
246
+ for (let j = i + 1; j < n; j++) {
247
+ const node1 = nodes[i];
248
+ const node2 = nodes[j];
249
+ if (node1 && node2) {
250
+ const hasEdge = graph.hasEdge(node1.id, node2.id);
251
+ const distance = hasEdge ? 1 : 2; // Connected: 1, not connected: 2
252
+ if (i < matrix.length) {
253
+ const rowI = matrix[i];
254
+ if (rowI && j < rowI.length) {
255
+ rowI[j] = distance;
256
+ }
257
+ }
258
+
259
+ if (j < matrix.length) {
260
+ const rowJ = matrix[j];
261
+ if (rowJ && i < rowJ.length) {
262
+ rowJ[i] = distance;
263
+ }
264
+ }
265
+ }
266
+ }
267
+ if (i < matrix.length) {
268
+ const row = matrix[i];
269
+ if (row && i < row.length) {
270
+ row[i] = 0;
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ return matrix;
277
+ }
278
+
279
+ /**
280
+ * BFS-based shortest path calculation from a source node
281
+ */
282
+ function bfsShortestPaths(graph: Graph, source: NodeId): Map<NodeId, number> {
283
+ const distances = new Map<NodeId, number>();
284
+ const queue: [NodeId, number][] = [[source, 0]];
285
+ const visited = new Set<NodeId>();
286
+
287
+ visited.add(source);
288
+ distances.set(source, 0);
289
+
290
+ while (queue.length > 0) {
291
+ const item = queue.shift();
292
+ if (!item) {
293
+ break;
294
+ }
295
+
296
+ const [current, distance] = item;
297
+
298
+ for (const neighbor of graph.neighbors(current)) {
299
+ if (!visited.has(neighbor)) {
300
+ visited.add(neighbor);
301
+ distances.set(neighbor, distance + 1);
302
+ queue.push([neighbor, distance + 1]);
303
+ }
304
+ }
305
+ }
306
+
307
+ return distances;
308
+ }
309
+
310
+ /**
311
+ * Initialize merge candidates priority queue
312
+ */
313
+ function initializeMergeCandidates(
314
+ clusters: Map<string, ClusterNode>,
315
+ distanceMatrix: number[][],
316
+ ): {cluster1Id: string, cluster2Id: string, distance: number}[] {
317
+ const candidates: {cluster1Id: string, cluster2Id: string, distance: number}[] = [];
318
+ const clusterIds = Array.from(clusters.keys());
319
+
320
+ for (let i = 0; i < clusterIds.length; i++) {
321
+ for (let j = i + 1; j < clusterIds.length; j++) {
322
+ const id1 = clusterIds[i];
323
+ const id2 = clusterIds[j];
324
+ if (!id1 || !id2) {
325
+ continue;
326
+ }
327
+
328
+ const row = distanceMatrix[parseInt(id1)];
329
+ if (!row) {
330
+ continue;
331
+ }
332
+
333
+ const distance = row[parseInt(id2)] ?? Infinity;
334
+ // Include all candidates, even disconnected ones (with finite but large distance)
335
+ candidates.push({
336
+ cluster1Id: id1,
337
+ cluster2Id: id2,
338
+ distance: distance === Infinity ? 100 : distance,
339
+ });
340
+ }
341
+ }
342
+
343
+ // Sort by distance (ascending)
344
+ candidates.sort((a, b) => a.distance - b.distance);
345
+ return candidates;
346
+ }
347
+
348
+ /**
349
+ * Find the closest pair of clusters
350
+ */
351
+ function findClosestPair(
352
+ mergeCandidates: {cluster1Id: string, cluster2Id: string, distance: number}[],
353
+ ): {cluster1Id: string, cluster2Id: string, distance: number} {
354
+ // Return the first (closest) valid candidate
355
+ const candidate = mergeCandidates.shift();
356
+ if (!candidate) {
357
+ throw new Error("No merge candidates available");
358
+ }
359
+
360
+ return candidate;
361
+ }
362
+
363
+ /**
364
+ * Update merge candidates after a merge operation
365
+ */
366
+ function updateMergeCandidates(
367
+ mergeCandidates: {cluster1Id: string, cluster2Id: string, distance: number}[],
368
+ oldCluster1Id: string,
369
+ oldCluster2Id: string,
370
+ newClusterId: string,
371
+ clusters: Map<string, ClusterNode>,
372
+ distanceMatrix: number[][],
373
+ linkage: string,
374
+ ): void {
375
+ // Remove candidates involving the merged clusters
376
+ for (let i = mergeCandidates.length - 1; i >= 0; i--) {
377
+ const candidate = mergeCandidates[i];
378
+ if (!candidate) {
379
+ continue;
380
+ }
381
+
382
+ if (candidate.cluster1Id === oldCluster1Id || candidate.cluster1Id === oldCluster2Id ||
383
+ candidate.cluster2Id === oldCluster1Id || candidate.cluster2Id === oldCluster2Id) {
384
+ mergeCandidates.splice(i, 1);
385
+ }
386
+ }
387
+
388
+ // Add new candidates for the merged cluster
389
+ const newCluster = clusters.get(newClusterId);
390
+ if (!newCluster) {
391
+ return;
392
+ }
393
+
394
+ for (const [clusterId, cluster] of clusters) {
395
+ if (clusterId !== newClusterId) {
396
+ const distance = calculateClusterDistance(newCluster, cluster, distanceMatrix, linkage);
397
+ mergeCandidates.push({
398
+ cluster1Id: newClusterId,
399
+ cluster2Id: clusterId,
400
+ distance: distance === Infinity ? 100 : distance,
401
+ });
402
+ }
403
+ }
404
+
405
+ // Re-sort candidates
406
+ mergeCandidates.sort((a, b) => a.distance - b.distance);
407
+ }
408
+
409
+ /**
410
+ * Calculate distance between two clusters based on linkage criterion
411
+ */
412
+ function calculateClusterDistance(
413
+ cluster1: ClusterNode,
414
+ cluster2: ClusterNode,
415
+ distanceMatrix: number[][],
416
+ linkage: string,
417
+ ): number {
418
+ const members1 = Array.from(cluster1.members);
419
+ const members2 = Array.from(cluster2.members);
420
+ const distances: number[] = [];
421
+
422
+ // Calculate all pairwise distances between cluster members
423
+ for (const member1 of members1) {
424
+ for (const member2 of members2) {
425
+ const idx1 = parseInt(member1.toString());
426
+ const idx2 = parseInt(member2.toString());
427
+ if (idx1 < distanceMatrix.length && idx2 < distanceMatrix.length) {
428
+ const row = distanceMatrix[idx1];
429
+ if (row) {
430
+ const distance = row[idx2];
431
+ if (distance !== undefined) {
432
+ distances.push(distance);
433
+ }
434
+ }
435
+ }
436
+ }
437
+ }
438
+
439
+ if (distances.length === 0) {
440
+ return Infinity;
441
+ }
442
+
443
+ // Apply linkage criterion
444
+ switch (linkage) {
445
+ case "single":
446
+ return Math.min(... distances);
447
+ case "complete":
448
+ return Math.max(... distances);
449
+ case "average":
450
+ return distances.reduce((sum, d) => sum + d, 0) / distances.length;
451
+ case "ward":
452
+ // Simplified Ward linkage (would need cluster centroids for full implementation)
453
+ return distances.reduce((sum, d) => sum + (d * d), 0) / distances.length;
454
+ default:
455
+ return distances.reduce((sum, d) => sum + d, 0) / distances.length;
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Extract flat clustering from dendrogram
461
+ */
462
+ function extractFlatClustering(dendrogram: ClusterNode, numClusters: number): Map<NodeId, number> {
463
+ const clusters = new Map<NodeId, number>();
464
+
465
+ if (numClusters === 1) {
466
+ // Single cluster
467
+ const clusterId = 0;
468
+ for (const member of dendrogram.members) {
469
+ clusters.set(member, clusterId);
470
+ }
471
+ return clusters;
472
+ }
473
+
474
+ // Find clusters at the specified level
475
+ const clusterNodes: ClusterNode[] = [];
476
+ const queue: ClusterNode[] = [dendrogram];
477
+
478
+ while (queue.length > 0 && clusterNodes.length < numClusters) {
479
+ const current = queue.shift();
480
+ if (!current) {
481
+ break;
482
+ }
483
+
484
+ if (!current.left || !current.right || clusterNodes.length + queue.length + 1 >= numClusters) {
485
+ // This is a leaf or we need to keep this level
486
+ clusterNodes.push(current);
487
+ } else {
488
+ // Continue decomposing
489
+ queue.push(current.left, current.right);
490
+ }
491
+ }
492
+
493
+ // Assign cluster IDs
494
+ for (let i = 0; i < clusterNodes.length; i++) {
495
+ const cluster = clusterNodes[i];
496
+ if (!cluster) {
497
+ continue;
498
+ }
499
+
500
+ for (const member of cluster.members) {
501
+ clusters.set(member, i);
502
+ }
503
+ }
504
+
505
+ return clusters;
506
+ }