@graphty/algorithms 1.8.1 → 2.0.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.
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/package.json +11 -8
  35. package/src/algorithms/centrality/eigenvector.ts +103 -84
  36. package/src/algorithms/community/girvan-newman.ts +38 -17
  37. package/src/algorithms/community/leiden.ts +270 -275
  38. package/src/algorithms/community/louvain-optimized.ts +303 -313
  39. package/src/algorithms/community/modularity-utils.ts +42 -45
  40. package/src/algorithms/shortest-path/bellman-ford.ts +46 -8
  41. package/src/errors.ts +26 -0
  42. package/src/index.ts +3 -0
  43. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -25,6 +25,9 @@ export interface LeidenResult {
25
25
  iterations: number;
26
26
  }
27
27
 
28
+ /** How many local-moving sweeps one level may take before it is declared settled. */
29
+ const LOCAL_MOVE_PASSES = 50;
30
+
28
31
  /**
29
32
  * Internal implementation of Leiden algorithm for community detection
30
33
  * Improves upon Louvain by ensuring well-connected communities
@@ -44,145 +47,101 @@ function leidenImpl(inputGraph: Map<string, Map<string, number>>, options: Leide
44
47
  };
45
48
  }
46
49
 
47
- // Use a mutable variable for the current graph state
48
- let currentGraph = inputGraph;
49
-
50
- // Initialize random number generator
51
50
  const random = SeededRandom.createGenerator(randomSeed);
52
-
53
- // Calculate total weight
54
- let totalWeight = 0;
55
- const degrees = new Map<string, number>();
56
-
57
- for (const [node, neighbors] of currentGraph) {
58
- let degree = 0;
59
- for (const weight of neighbors.values()) {
60
- degree += weight;
61
- totalWeight += weight;
62
- }
63
- degrees.set(node, degree);
51
+ const base = weighLevel(inputGraph);
52
+
53
+ // Which node of the CURRENT level holds each original node. Every partition is scored against
54
+ // the original graph through this map, so an aggregation can never leave the answer keyed by
55
+ // the super-node names a caller has never heard of -- which is what it used to do.
56
+ const placement = new Map<string, string>();
57
+ for (const node of inputGraph.keys()) {
58
+ placement.set(node, node);
64
59
  }
65
- totalWeight /= 2; // Each edge counted twice
66
60
 
67
- // Initialize communities - each node in its own community
68
- const communities = new Map<string, number>();
69
- const nodes = Array.from(currentGraph.keys());
70
- nodes.forEach((node, i) => communities.set(node, i));
71
-
72
- let modularity = calculateModularity(currentGraph, communities, degrees, totalWeight, resolution);
73
- let bestModularity = modularity;
74
- let bestCommunities = new Map(communities);
61
+ let bestCommunities = singletonCommunities(inputGraph);
62
+ let bestModularity = calculateModularity(inputGraph, bestCommunities, base.degrees, base.totalWeight, resolution);
63
+ let levelGraph = inputGraph;
75
64
  let iterations = 0;
76
65
 
77
- // Main Leiden loop
66
+ // Whether this level is the ORIGINAL graph, started from the best partition so far rather than
67
+ // from singletons. Local moving on aggregated levels can only move whole super-nodes, so a node
68
+ // merged into the wrong community early could never leave it, and the answer could be beaten by
69
+ // moving one node. The paper (Traag et al. 2019) iterates until a pass over the original nodes
70
+ // changes nothing; returning to them once the levels settle is that pass.
71
+ let polishing = false;
72
+
78
73
  while (iterations < maxIterations) {
79
74
  iterations++;
80
- let improved = false;
81
75
 
82
- // Phase 1: Local moving of nodes (fast)
83
- const nodeOrder = [...nodes];
84
- shuffle(nodeOrder, random);
76
+ const weights = weighLevel(levelGraph);
77
+ const communities = polishing ? new Map(bestCommunities) : singletonCommunities(levelGraph);
78
+ const moved = moveNodesLocally(levelGraph, weights, communities, random, resolution, LOCAL_MOVE_PASSES);
85
79
 
86
- for (const node of nodeOrder) {
87
- const currentCommunity = communities.get(node);
88
- if (currentCommunity === undefined) {
89
- continue;
90
- }
80
+ // Leiden's guarantee over Louvain: a community whose members are not connected to each
81
+ // other inside this level's graph is split into the pieces that are.
82
+ for (const [node, piece] of refinePartition(levelGraph, communities)) {
83
+ communities.set(node, piece);
84
+ }
91
85
 
92
- const neighborCommunities = getNeighborCommunities(node, currentGraph, communities);
86
+ const candidate = new Map<string, number>();
87
+ for (const [original, levelNode] of placement) {
88
+ const community = communities.get(levelNode);
89
+ if (community !== undefined) {
90
+ candidate.set(original, community);
91
+ }
92
+ }
93
93
 
94
- let bestCommunity = currentCommunity;
95
- let bestGain = 0;
94
+ const modularity = calculateModularity(inputGraph, candidate, base.degrees, base.totalWeight, resolution);
95
+ const improved = modularity > bestModularity + threshold;
96
96
 
97
- // Try moving to each neighbor community
98
- for (const [community] of neighborCommunities) {
99
- if (community === currentCommunity) {
100
- continue;
101
- }
97
+ if (modularity > bestModularity) {
98
+ bestModularity = modularity;
99
+ bestCommunities = candidate;
100
+ }
102
101
 
103
- const gain = calculateModularityGain(
104
- node,
105
- community,
106
- currentGraph,
107
- communities,
108
- degrees,
109
- totalWeight,
110
- resolution,
111
- );
102
+ const distinct = new Set(communities.values()).size;
103
+ const settled = polishing ? !improved : !improved || !moved || distinct === levelGraph.size;
112
104
 
113
- if (gain > bestGain) {
114
- bestGain = gain;
115
- bestCommunity = community;
116
- }
105
+ if (settled) {
106
+ if (polishing) {
107
+ break;
117
108
  }
118
109
 
119
- // Move node if beneficial
120
- if (bestCommunity !== currentCommunity) {
121
- communities.set(node, bestCommunity);
122
- modularity += bestGain;
123
- improved = true;
110
+ // The levels have settled: go back to the original nodes, starting from the answer.
111
+ polishing = true;
112
+ levelGraph = inputGraph;
113
+ for (const node of inputGraph.keys()) {
114
+ placement.set(node, node);
124
115
  }
125
- }
126
116
 
127
- // Phase 2: Refinement (Leiden improvement over Louvain)
128
- // Create aggregate network based on current partition
129
- createAggregateNetwork(currentGraph, communities);
130
-
131
- // Refine partition using aggregate network
132
- const subsetPartition = refinePartition(currentGraph, communities);
133
-
134
- // Apply refined partition
135
- for (const [node, newCommunity] of subsetPartition) {
136
- communities.set(node, newCommunity);
117
+ continue;
137
118
  }
138
119
 
139
- // Recalculate modularity
140
- modularity = calculateModularity(currentGraph, communities, degrees, totalWeight, resolution);
120
+ polishing = false;
141
121
 
142
- // Check if we've improved
143
- if (modularity > bestModularity + threshold) {
144
- bestModularity = modularity;
145
- bestCommunities = new Map(communities);
146
- improved = true;
147
- }
148
-
149
- if (!improved) {
150
- break;
122
+ const aggregated = aggregateLevel(levelGraph, communities);
123
+ for (const [original, levelNode] of placement) {
124
+ const superNode = aggregated.mapping.get(levelNode);
125
+ if (superNode !== undefined) {
126
+ placement.set(original, superNode);
127
+ }
151
128
  }
152
129
 
153
- // Phase 3: Aggregate network (create super-nodes)
154
- const aggregated = aggregateCommunities(currentGraph, communities);
155
- if (aggregated.graph.size === currentGraph.size) {
156
- break;
157
- } // No aggregation possible
158
-
159
- // Continue with aggregated network
160
- const { graph: aggregatedGraph } = aggregated;
161
- currentGraph = aggregatedGraph;
162
- communities.clear();
163
- let communityId = 0;
164
- for (const node of currentGraph.keys()) {
165
- communities.set(node, communityId++);
166
- }
130
+ levelGraph = aggregated.graph;
167
131
  }
168
132
 
169
- // Map back to original nodes
133
+ // Renumber communities consecutively, in the order they are first met, so the ids a caller
134
+ // reads are 0..k-1 whatever the levels handed out along the way.
135
+ const renumbered = new Map<number, number>();
170
136
  const finalCommunities = new Map<string, number>();
171
137
  for (const [node, community] of bestCommunities) {
172
- finalCommunities.set(node, community);
173
- }
174
-
175
- // Renumber communities consecutively
176
- const communityRenumber = new Map<number, number>();
177
- let newId = 0;
178
- for (const community of new Set(finalCommunities.values())) {
179
- communityRenumber.set(community, newId++);
180
- }
181
- for (const [node, community] of finalCommunities) {
182
- const newCommunityId = communityRenumber.get(community);
183
- if (newCommunityId !== undefined) {
184
- finalCommunities.set(node, newCommunityId);
138
+ let id = renumbered.get(community);
139
+ if (id === undefined) {
140
+ id = renumbered.size;
141
+ renumbered.set(community, id);
185
142
  }
143
+
144
+ finalCommunities.set(node, id);
186
145
  }
187
146
 
188
147
  return {
@@ -278,127 +237,6 @@ function getNeighborCommunities(
278
237
  return neighborCommunities;
279
238
  }
280
239
 
281
- /**
282
- * Calculate modularity gain from moving a node to a community
283
- * @param node - The node ID to move
284
- * @param targetCommunity - The target community ID
285
- * @param graph - Map representation of the graph
286
- * @param communities - Map of node IDs to community IDs
287
- * @param degrees - Map of node IDs to their weighted degrees
288
- * @param totalWeight - Total weight of all edges in the graph
289
- * @param resolution - Resolution parameter for modularity calculation
290
- * @returns The modularity gain from moving the node to the target community
291
- */
292
- function calculateModularityGain(
293
- node: string,
294
- targetCommunity: number,
295
- graph: Map<string, Map<string, number>>,
296
- communities: Map<string, number>,
297
- degrees: Map<string, number>,
298
- totalWeight: number,
299
- resolution: number,
300
- ): number {
301
- const currentCommunity = communities.get(node);
302
- const nodeDegree = degrees.get(node);
303
- if (currentCommunity === undefined || nodeDegree === undefined) {
304
- return 0;
305
- }
306
-
307
- // Weight of edges from node to target community
308
- let weightToTarget = 0;
309
- let weightToCurrent = 0;
310
-
311
- const neighbors = graph.get(node);
312
- if (neighbors) {
313
- for (const [neighbor, weight] of neighbors) {
314
- const neighborCommunity = communities.get(neighbor);
315
- if (neighborCommunity === undefined) {
316
- continue;
317
- }
318
-
319
- if (neighborCommunity === targetCommunity) {
320
- weightToTarget += weight;
321
- } else if (neighborCommunity === currentCommunity && neighbor !== node) {
322
- weightToCurrent += weight;
323
- }
324
- }
325
- }
326
-
327
- // Calculate community degrees
328
- let targetDegree = 0;
329
- let currentDegree = 0;
330
-
331
- for (const [n, c] of communities) {
332
- if (c === targetCommunity && n !== node) {
333
- const deg = degrees.get(n);
334
- if (deg !== undefined) {
335
- targetDegree += deg;
336
- }
337
- } else if (c === currentCommunity && n !== node) {
338
- const deg = degrees.get(n);
339
- if (deg !== undefined) {
340
- currentDegree += deg;
341
- }
342
- }
343
- }
344
-
345
- // Modularity gain calculation
346
- const m2 = 2 * totalWeight;
347
- const gain =
348
- (weightToTarget - weightToCurrent) / totalWeight -
349
- (resolution * nodeDegree * (targetDegree - currentDegree)) / (m2 * m2);
350
-
351
- return gain;
352
- }
353
-
354
- /**
355
- * Create aggregate network where each community becomes a super-node
356
- * @param graph - Map representation of the graph
357
- * @param communities - Map of node IDs to community IDs
358
- * @returns Object with aggregate graph and node-to-community mapping
359
- */
360
- function createAggregateNetwork(
361
- graph: Map<string, Map<string, number>>,
362
- communities: Map<string, number>,
363
- ): {
364
- aggregateGraph: Map<number, Map<number, number>>;
365
- nodeMapping: Map<string, number>;
366
- } {
367
- const aggregateGraph = new Map<number, Map<number, number>>();
368
- const nodeMapping = new Map<string, number>();
369
-
370
- // Create mapping from nodes to communities
371
- for (const [node, community] of communities) {
372
- nodeMapping.set(node, community);
373
- if (!aggregateGraph.has(community)) {
374
- aggregateGraph.set(community, new Map());
375
- }
376
- }
377
-
378
- // Aggregate edges
379
- for (const [node, neighbors] of graph) {
380
- const sourceCommunity = communities.get(node);
381
- if (sourceCommunity === undefined) {
382
- continue;
383
- }
384
-
385
- for (const [neighbor, weight] of neighbors) {
386
- const targetCommunity = communities.get(neighbor);
387
- if (targetCommunity === undefined) {
388
- continue;
389
- }
390
-
391
- const sourceNeighbors = aggregateGraph.get(sourceCommunity);
392
- if (sourceNeighbors) {
393
- const current = sourceNeighbors.get(targetCommunity) ?? 0;
394
- sourceNeighbors.set(targetCommunity, current + weight);
395
- }
396
- }
397
- }
398
-
399
- return { aggregateGraph, nodeMapping };
400
- }
401
-
402
240
  /**
403
241
  * Refine partition (Leiden-specific improvement)
404
242
  * Ensures well-connected communities by considering subsets
@@ -511,67 +349,224 @@ function findConnectedComponents(graph: Map<string, Set<string>>): Set<string>[]
511
349
  }
512
350
 
513
351
  /**
514
- * Aggregate communities into super-nodes
515
- * @param graph - Map representation of the graph
516
- * @param communities - Map of node IDs to community IDs
517
- * @returns Object with aggregated graph and node-to-supernode mapping
352
+ * What one level of the algorithm knows about its own graph.
353
+ *
354
+ * RECOMPUTED PER LEVEL, which is the half that used to be missing. After the first aggregation
355
+ * every node is a super-node, and scoring the new graph with the old graph's degrees made the
356
+ * second level's modularity a number about a graph that no longer existed. It came out at zero,
357
+ * the loop read that as "no improvement", and the algorithm stopped after one pass.
358
+ */
359
+ interface LevelWeights {
360
+ /** The weighted degree of each node at this level, self-loops included. */
361
+ degrees: Map<string, number>;
362
+ /** Half the summed degree: the total edge weight m, each undirected edge counted once. */
363
+ totalWeight: number;
364
+ }
365
+
366
+ /**
367
+ * Weigh one level's graph.
368
+ * @param graph - The level's graph, stored with both directions of every edge.
369
+ * @returns The degrees and the total edge weight.
370
+ */
371
+ function weighLevel(graph: Map<string, Map<string, number>>): LevelWeights {
372
+ const degrees = new Map<string, number>();
373
+ let summed = 0;
374
+
375
+ for (const [node, neighbors] of graph) {
376
+ let degree = 0;
377
+ for (const weight of neighbors.values()) {
378
+ degree += weight;
379
+ }
380
+
381
+ degrees.set(node, degree);
382
+ summed += degree;
383
+ }
384
+
385
+ return { degrees, totalWeight: summed / 2 };
386
+ }
387
+
388
+ /**
389
+ * One node per community, numbered from zero in the order the graph lists its nodes.
390
+ * @param graph - The level's graph.
391
+ * @returns The partition every level starts from.
518
392
  */
519
- function aggregateCommunities(
393
+ function singletonCommunities(graph: Map<string, Map<string, number>>): Map<string, number> {
394
+ const communities = new Map<string, number>();
395
+ let next = 0;
396
+
397
+ for (const node of graph.keys()) {
398
+ communities.set(node, next++);
399
+ }
400
+
401
+ return communities;
402
+ }
403
+
404
+ /**
405
+ * The summed degree of each community's members, which local moving keeps in step with every move.
406
+ * @param communities - The partition.
407
+ * @param degrees - The weighted degree of each node at this level.
408
+ * @returns The summed degree per community.
409
+ */
410
+ function communityDegrees(communities: Map<string, number>, degrees: Map<string, number>): Map<number, number> {
411
+ const totals = new Map<number, number>();
412
+
413
+ for (const [node, community] of communities) {
414
+ totals.set(community, (totals.get(community) ?? 0) + (degrees.get(node) ?? 0));
415
+ }
416
+
417
+ return totals;
418
+ }
419
+
420
+ /**
421
+ * Move nodes to neighbouring communities until a whole sweep changes nothing.
422
+ *
423
+ * ONE SWEEP IS NOT CONVERGENCE, and treating it as such is what left this algorithm returning
424
+ * partitions it could beat with six single-node moves. A move changes the communities its
425
+ * neighbours are weighed against, so the node visited first is judged against a partition that no
426
+ * longer exists by the time the sweep ends. Repeating until a sweep moves nobody is what makes
427
+ * the answer a local optimum of the objective rather than an artefact of the visiting order.
428
+ * @param graph - The level's graph.
429
+ * @param weights - Its degrees and total edge weight.
430
+ * @param communities - The partition, moved in place.
431
+ * @param random - The seeded generator the visiting order is shuffled with.
432
+ * @param resolution - Higher values favour smaller communities.
433
+ * @param maxPasses - A ceiling on the sweeps, so a pathological graph cannot spin for ever.
434
+ * @returns Whether anything moved at all.
435
+ */
436
+ function moveNodesLocally(
520
437
  graph: Map<string, Map<string, number>>,
438
+ weights: LevelWeights,
521
439
  communities: Map<string, number>,
522
- ): {
523
- graph: Map<string, Map<string, number>>;
524
- mapping: Map<string, string>;
525
- } {
526
- const aggregated = new Map<string, Map<string, number>>();
527
- const mapping = new Map<string, string>();
440
+ random: () => number,
441
+ resolution: number,
442
+ maxPasses: number,
443
+ ): boolean {
444
+ const { degrees, totalWeight } = weights;
445
+
446
+ if (totalWeight === 0) {
447
+ return false;
448
+ }
528
449
 
529
- // Create super-nodes
530
- const communityNodes = new Map<number, string>();
531
- for (const community of new Set(communities.values())) {
532
- const superNode = `super_${String(community)}`;
533
- communityNodes.set(community, superNode);
534
- aggregated.set(superNode, new Map());
450
+ const totals = communityDegrees(communities, degrees);
451
+ const nodes = [...graph.keys()];
452
+ let movedEver = false;
453
+ // A fresh id for a node that does better on its own, the empty community the paper also offers.
454
+ let nextEmpty = 0;
455
+ for (const community of communities.values()) {
456
+ nextEmpty = Math.max(nextEmpty, community + 1);
535
457
  }
536
458
 
537
- // Map original nodes to super-nodes
538
- for (const [node, community] of communities) {
539
- const superNode = communityNodes.get(community);
540
- if (superNode !== undefined) {
541
- mapping.set(node, superNode);
459
+ for (let pass = 0; pass < maxPasses; pass++) {
460
+ const order = [...nodes];
461
+ shuffle(order, random);
462
+ let movedThisPass = false;
463
+
464
+ for (const node of order) {
465
+ const current = communities.get(node);
466
+ const degree = degrees.get(node);
467
+ if (current === undefined || degree === undefined) {
468
+ continue;
469
+ }
470
+
471
+ const neighborCommunities = getNeighborCommunities(node, graph, communities);
472
+ const selfLoop = graph.get(node)?.get(node) ?? 0;
473
+ const weightToCurrent = (neighborCommunities.get(current) ?? 0) - selfLoop;
474
+ const degreeOfCurrent = (totals.get(current) ?? 0) - degree;
475
+
476
+ let bestCommunity = current;
477
+ let bestGain = 0;
478
+
479
+ // Leaving for an empty community: the target has no weight and no degree.
480
+ if (degreeOfCurrent > 0) {
481
+ const alone =
482
+ -weightToCurrent / totalWeight +
483
+ (resolution * degree * degreeOfCurrent) / (2 * totalWeight * totalWeight);
484
+ if (alone > bestGain) {
485
+ bestGain = alone;
486
+ bestCommunity = nextEmpty;
487
+ }
488
+ }
489
+
490
+ for (const [community, weightToTarget] of neighborCommunities) {
491
+ if (community === current) {
492
+ continue;
493
+ }
494
+
495
+ // The standard modularity gain for moving one node out of its community and into
496
+ // another: what the move adds to the edges inside a community, less what it adds
497
+ // to what a random graph of the same degrees would have had there.
498
+ const gain =
499
+ (weightToTarget - weightToCurrent) / totalWeight -
500
+ (resolution * degree * ((totals.get(community) ?? 0) - degreeOfCurrent)) /
501
+ (2 * totalWeight * totalWeight);
502
+
503
+ if (gain > bestGain) {
504
+ bestGain = gain;
505
+ bestCommunity = community;
506
+ }
507
+ }
508
+
509
+ if (bestCommunity !== current) {
510
+ if (bestCommunity === nextEmpty) {
511
+ nextEmpty++;
512
+ }
513
+
514
+ communities.set(node, bestCommunity);
515
+ totals.set(current, (totals.get(current) ?? 0) - degree);
516
+ totals.set(bestCommunity, (totals.get(bestCommunity) ?? 0) + degree);
517
+ movedThisPass = true;
518
+ movedEver = true;
519
+ }
520
+ }
521
+
522
+ if (!movedThisPass) {
523
+ break;
542
524
  }
543
525
  }
544
526
 
545
- // Aggregate edges
546
- for (const [node, neighbors] of graph) {
547
- const sourceCommunity = communities.get(node);
548
- if (sourceCommunity === undefined) {
549
- continue;
527
+ return movedEver;
528
+ }
529
+
530
+ /**
531
+ * Collapse each community into one node, carrying its internal weight as a self-loop.
532
+ *
533
+ * THE SELF-LOOP IS THE POINT. Without it the aggregated graph forgets every edge inside a
534
+ * community, so the next level scores a graph with no internal weight anywhere and every
535
+ * partition of it looks equally bad. Both directions of every internal edge land in the same
536
+ * entry, which is exactly the doubled internal weight the modularity sum wants.
537
+ * @param graph - The level's graph.
538
+ * @param communities - The partition to collapse.
539
+ * @returns The next level's graph, and where each node of this one went.
540
+ */
541
+ function aggregateLevel(
542
+ graph: Map<string, Map<string, number>>,
543
+ communities: Map<string, number>,
544
+ ): { graph: Map<string, Map<string, number>>; mapping: Map<string, string> } {
545
+ const aggregated = new Map<string, Map<string, number>>();
546
+ const mapping = new Map<string, string>();
547
+
548
+ for (const [node, community] of communities) {
549
+ const superNode = `c${String(community)}`;
550
+ mapping.set(node, superNode);
551
+ if (!aggregated.has(superNode)) {
552
+ aggregated.set(superNode, new Map());
550
553
  }
554
+ }
551
555
 
552
- const sourceSuper = communityNodes.get(sourceCommunity);
553
- if (sourceSuper === undefined) {
556
+ for (const [node, neighbors] of graph) {
557
+ const source = mapping.get(node);
558
+ const row = source === undefined ? undefined : aggregated.get(source);
559
+ if (row === undefined) {
554
560
  continue;
555
561
  }
556
562
 
557
563
  for (const [neighbor, weight] of neighbors) {
558
- const targetCommunity = communities.get(neighbor);
559
- if (targetCommunity === undefined) {
560
- continue;
561
- }
562
-
563
- const targetSuper = communityNodes.get(targetCommunity);
564
- if (targetSuper === undefined) {
564
+ const target = mapping.get(neighbor);
565
+ if (target === undefined) {
565
566
  continue;
566
567
  }
567
568
 
568
- if (sourceSuper !== targetSuper) {
569
- const sourceNeighbors = aggregated.get(sourceSuper);
570
- if (sourceNeighbors) {
571
- const current = sourceNeighbors.get(targetSuper) ?? 0;
572
- sourceNeighbors.set(targetSuper, current + weight);
573
- }
574
- }
569
+ row.set(target, (row.get(target) ?? 0) + weight);
575
570
  }
576
571
  }
577
572