graphwise 1.5.0 → 1.5.2

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 (51) hide show
  1. package/dist/__test__/fixtures/graphs/index.d.ts +1 -0
  2. package/dist/__test__/fixtures/graphs/index.d.ts.map +1 -1
  3. package/dist/__test__/fixtures/graphs/linear-chain.d.ts +18 -0
  4. package/dist/__test__/fixtures/graphs/linear-chain.d.ts.map +1 -0
  5. package/dist/__test__/fixtures/helpers.d.ts +20 -1
  6. package/dist/__test__/fixtures/helpers.d.ts.map +1 -1
  7. package/dist/expansion/comparison.integration.test.d.ts +14 -0
  8. package/dist/expansion/comparison.integration.test.d.ts.map +1 -0
  9. package/dist/expansion/edge.d.ts.map +1 -1
  10. package/dist/expansion/flux.d.ts.map +1 -1
  11. package/dist/expansion/fuse.d.ts.map +1 -1
  12. package/dist/expansion/lace.d.ts.map +1 -1
  13. package/dist/expansion/maze.d.ts.map +1 -1
  14. package/dist/expansion/priority-helpers.d.ts +43 -0
  15. package/dist/expansion/priority-helpers.d.ts.map +1 -0
  16. package/dist/expansion/sage.d.ts.map +1 -1
  17. package/dist/expansion/sift.d.ts.map +1 -1
  18. package/dist/expansion/warp.d.ts.map +1 -1
  19. package/dist/index/index.cjs +186 -307
  20. package/dist/index/index.cjs.map +1 -1
  21. package/dist/index/index.js +187 -309
  22. package/dist/index/index.js.map +1 -1
  23. package/dist/ranking/baselines/betweenness.d.ts.map +1 -1
  24. package/dist/ranking/baselines/communicability.d.ts.map +1 -1
  25. package/dist/ranking/baselines/degree-sum.d.ts.map +1 -1
  26. package/dist/ranking/baselines/hitting-time.d.ts.map +1 -1
  27. package/dist/ranking/baselines/jaccard-arithmetic.d.ts.map +1 -1
  28. package/dist/ranking/baselines/katz.d.ts.map +1 -1
  29. package/dist/ranking/baselines/pagerank.d.ts.map +1 -1
  30. package/dist/ranking/baselines/random-ranking.d.ts.map +1 -1
  31. package/dist/ranking/baselines/resistance-distance.d.ts.map +1 -1
  32. package/dist/ranking/baselines/shortest.d.ts.map +1 -1
  33. package/dist/ranking/baselines/utils.d.ts +20 -0
  34. package/dist/ranking/baselines/utils.d.ts.map +1 -0
  35. package/dist/ranking/baselines/widest-path.d.ts.map +1 -1
  36. package/dist/ranking/mi/adaptive.d.ts.map +1 -1
  37. package/dist/ranking/mi/comparison.integration.test.d.ts +18 -0
  38. package/dist/ranking/mi/comparison.integration.test.d.ts.map +1 -0
  39. package/dist/ranking/mi/etch.d.ts.map +1 -1
  40. package/dist/ranking/mi/jaccard.d.ts.map +1 -1
  41. package/dist/ranking/mi/notch.d.ts.map +1 -1
  42. package/dist/ranking/mi/scale.d.ts.map +1 -1
  43. package/dist/ranking/mi/skew.d.ts.map +1 -1
  44. package/dist/ranking/mi/span.d.ts.map +1 -1
  45. package/dist/utils/index.cjs +22 -0
  46. package/dist/utils/index.cjs.map +1 -1
  47. package/dist/utils/index.js +22 -1
  48. package/dist/utils/index.js.map +1 -1
  49. package/dist/utils/neighbours.d.ts +23 -0
  50. package/dist/utils/neighbours.d.ts.map +1 -1
  51. package/package.json +1 -1
@@ -255,46 +255,12 @@ function domeHighDegree(graph, seeds, config) {
255
255
  });
256
256
  }
257
257
  //#endregion
258
- //#region src/expansion/edge.ts
259
- var EPSILON$1 = 1e-10;
260
- /**
261
- * Priority function using local type entropy.
262
- * Lower values = higher priority (expanded first).
263
- */
264
- function edgePriority(nodeId, context) {
265
- const graph = context.graph;
266
- const neighbours = graph.neighbours(nodeId);
267
- const neighbourTypes = [];
268
- for (const neighbour of neighbours) {
269
- const node = graph.getNode(neighbour);
270
- neighbourTypes.push(node?.type ?? "default");
271
- }
272
- return 1 / (require_utils.localTypeEntropy(neighbourTypes) + EPSILON$1) * Math.log(context.degree + 1);
273
- }
274
- /**
275
- * Run EDGE expansion (Entropy-Driven Graph Expansion).
276
- *
277
- * Discovers paths by prioritising nodes with diverse neighbour types,
278
- * deferring nodes with homogeneous neighbourhoods.
279
- *
280
- * @param graph - Source graph
281
- * @param seeds - Seed nodes for expansion
282
- * @param config - Expansion configuration
283
- * @returns Expansion result with discovered paths
284
- */
285
- function edge(graph, seeds, config) {
286
- return base(graph, seeds, {
287
- ...config,
288
- priority: edgePriority
289
- });
290
- }
291
- //#endregion
292
258
  //#region src/expansion/hae.ts
293
259
  var EPSILON = 1e-10;
294
260
  /**
295
261
  * Default type mapper - uses node.type property.
296
262
  */
297
- function defaultTypeMapper(node) {
263
+ function defaultTypeMapper$1(node) {
298
264
  return node.type ?? "default";
299
265
  }
300
266
  /**
@@ -324,13 +290,34 @@ function createHAEPriority(typeMapper) {
324
290
  * @returns Expansion result with discovered paths
325
291
  */
326
292
  function hae(graph, seeds, config) {
327
- const typeMapper = config?.typeMapper ?? defaultTypeMapper;
293
+ const typeMapper = config?.typeMapper ?? defaultTypeMapper$1;
328
294
  return base(graph, seeds, {
329
295
  ...config,
330
296
  priority: createHAEPriority(typeMapper)
331
297
  });
332
298
  }
333
299
  //#endregion
300
+ //#region src/expansion/edge.ts
301
+ /** Default type mapper: reads `node.type`, falling back to "default". */
302
+ var defaultTypeMapper = (n) => typeof n.type === "string" ? n.type : "default";
303
+ /**
304
+ * Run EDGE expansion (Entropy-Driven Graph Expansion).
305
+ *
306
+ * Discovers paths by prioritising nodes with diverse neighbour types,
307
+ * deferring nodes with homogeneous neighbourhoods.
308
+ *
309
+ * @param graph - Source graph
310
+ * @param seeds - Seed nodes for expansion
311
+ * @param config - Expansion configuration
312
+ * @returns Expansion result with discovered paths
313
+ */
314
+ function edge(graph, seeds, config) {
315
+ return hae(graph, seeds, {
316
+ ...config,
317
+ typeMapper: defaultTypeMapper
318
+ });
319
+ }
320
+ //#endregion
334
321
  //#region src/expansion/pipe.ts
335
322
  /**
336
323
  * Priority function using path potential.
@@ -366,6 +353,69 @@ function pipe(graph, seeds, config) {
366
353
  });
367
354
  }
368
355
  //#endregion
356
+ //#region src/expansion/priority-helpers.ts
357
+ /**
358
+ * Compute the average mutual information between a node and all visited
359
+ * nodes in the same frontier.
360
+ *
361
+ * Returns a value in [0, 1] — higher means the node is more similar
362
+ * (on average) to already-visited same-frontier nodes.
363
+ *
364
+ * @param graph - Source graph
365
+ * @param nodeId - Node being prioritised
366
+ * @param context - Current priority context
367
+ * @param mi - MI function to use for pairwise scoring
368
+ * @returns Average MI score, or 0 if no same-frontier visited nodes exist
369
+ */
370
+ function avgFrontierMI(graph, nodeId, context, mi) {
371
+ const { frontierIndex, visitedByFrontier } = context;
372
+ let total = 0;
373
+ let count = 0;
374
+ for (const [visitedId, idx] of visitedByFrontier) if (idx === frontierIndex && visitedId !== nodeId) {
375
+ total += mi(graph, visitedId, nodeId);
376
+ count++;
377
+ }
378
+ return count > 0 ? total / count : 0;
379
+ }
380
+ /**
381
+ * Count the number of a node's neighbours that have been visited by
382
+ * frontiers other than the node's own frontier.
383
+ *
384
+ * A higher count indicates this node is likely to bridge two frontiers,
385
+ * making it a strong candidate for path completion.
386
+ *
387
+ * @param graph - Source graph
388
+ * @param nodeId - Node being evaluated
389
+ * @param context - Current priority context
390
+ * @returns Number of neighbours visited by other frontiers
391
+ */
392
+ function countCrossFrontierNeighbours(graph, nodeId, context) {
393
+ const { frontierIndex, visitedByFrontier } = context;
394
+ const nodeNeighbours = new Set(graph.neighbours(nodeId));
395
+ let count = 0;
396
+ for (const [visitedId, idx] of visitedByFrontier) if (idx !== frontierIndex && nodeNeighbours.has(visitedId)) count++;
397
+ return count;
398
+ }
399
+ /**
400
+ * Incrementally update salience counts for paths discovered since the
401
+ * last update.
402
+ *
403
+ * Iterates only over paths from `fromIndex` onwards, avoiding redundant
404
+ * re-processing of already-counted paths.
405
+ *
406
+ * @param salienceCounts - Mutable map of node ID to salience count (mutated in place)
407
+ * @param paths - Full list of discovered paths
408
+ * @param fromIndex - Index to start counting from (exclusive of earlier paths)
409
+ * @returns The new `fromIndex` value (i.e. `paths.length` after update)
410
+ */
411
+ function updateSalienceCounts(salienceCounts, paths, fromIndex) {
412
+ for (let i = fromIndex; i < paths.length; i++) {
413
+ const path = paths[i];
414
+ if (path !== void 0) for (const node of path.nodes) salienceCounts.set(node, (salienceCounts.get(node) ?? 0) + 1);
415
+ }
416
+ return paths.length;
417
+ }
418
+ //#endregion
369
419
  //#region src/expansion/sage.ts
370
420
  /**
371
421
  * Run SAGE expansion algorithm.
@@ -389,13 +439,7 @@ function sage(graph, seeds, config) {
389
439
  function sagePriority(nodeId, context) {
390
440
  const pathCount = context.discoveredPaths.length;
391
441
  if (pathCount > 0 && !inPhase2) inPhase2 = true;
392
- if (pathCount > lastPathCount) {
393
- for (let i = lastPathCount; i < pathCount; i++) {
394
- const path = context.discoveredPaths[i];
395
- if (path !== void 0) for (const node of path.nodes) salienceCounts.set(node, (salienceCounts.get(node) ?? 0) + 1);
396
- }
397
- lastPathCount = pathCount;
398
- }
442
+ if (pathCount > lastPathCount) lastPathCount = updateSalienceCounts(salienceCounts, context.discoveredPaths, lastPathCount);
399
443
  if (!inPhase2) return Math.log(context.degree + 1);
400
444
  return -((salienceCounts.get(nodeId) ?? 0) * 1e3 - context.degree);
401
445
  }
@@ -417,10 +461,9 @@ function sage(graph, seeds, config) {
417
461
  */
418
462
  function jaccard(graph, source, target, config) {
419
463
  const { epsilon = 1e-10 } = config ?? {};
420
- const { intersection, union } = require_utils.neighbourOverlap(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
421
- if (union === 0) return 0;
422
- const score = intersection / union;
423
- return Math.max(epsilon, score);
464
+ const { jaccard: jaccardScore, sourceNeighbours, targetNeighbours } = require_utils.computeJaccard(graph, source, target);
465
+ if (sourceNeighbours.size === 0 && targetNeighbours.size === 0) return 0;
466
+ return Math.max(epsilon, jaccardScore);
424
467
  }
425
468
  //#endregion
426
469
  //#region src/expansion/reach.ts
@@ -506,15 +549,9 @@ function maze(graph, seeds, config) {
506
549
  const pathCount = context.discoveredPaths.length;
507
550
  if (pathCount >= DEFAULT_PHASE2_THRESHOLD && !inPhase2) {
508
551
  inPhase2 = true;
509
- for (const path of context.discoveredPaths) for (const node of path.nodes) salienceCounts.set(node, (salienceCounts.get(node) ?? 0) + 1);
510
- }
511
- if (inPhase2 && pathCount > lastPathCount) {
512
- for (let i = lastPathCount; i < pathCount; i++) {
513
- const path = context.discoveredPaths[i];
514
- if (path !== void 0) for (const node of path.nodes) salienceCounts.set(node, (salienceCounts.get(node) ?? 0) + 1);
515
- }
516
- lastPathCount = pathCount;
552
+ updateSalienceCounts(salienceCounts, context.discoveredPaths, 0);
517
553
  }
554
+ if (inPhase2 && pathCount > lastPathCount) lastPathCount = updateSalienceCounts(salienceCounts, context.discoveredPaths, lastPathCount);
518
555
  const nodeNeighbours = graph.neighbours(nodeId);
519
556
  let pathPotential = 0;
520
557
  for (const neighbour of nodeNeighbours) {
@@ -566,22 +603,11 @@ function tide(graph, seeds, config) {
566
603
  /**
567
604
  * LACE priority function.
568
605
  *
569
- * Priority = 1 - MI(source, neighbour)
570
- * Higher MI = lower priority value = explored first
606
+ * Priority = 1 - avgMI(node, same-frontier visited nodes)
607
+ * Higher average MI = lower priority value = explored first
571
608
  */
572
609
  function lacePriority(nodeId, context, mi) {
573
- const graph = context.graph;
574
- const frontierIndex = context.frontierIndex;
575
- let maxMi = 0;
576
- let totalMi = 0;
577
- let count = 0;
578
- for (const [visitedId, idx] of context.visitedByFrontier) if (idx === frontierIndex && visitedId !== nodeId) {
579
- const edgeMi = mi(graph, visitedId, nodeId);
580
- totalMi += edgeMi;
581
- count++;
582
- if (edgeMi > maxMi) maxMi = edgeMi;
583
- }
584
- return 1 - (count > 0 ? totalMi / count : 0);
610
+ return 1 - avgFrontierMI(context.graph, nodeId, context, mi);
585
611
  }
586
612
  /**
587
613
  * Run LACE expansion algorithm.
@@ -605,18 +631,15 @@ function lace(graph, seeds, config) {
605
631
  //#endregion
606
632
  //#region src/expansion/warp.ts
607
633
  /**
608
- * PIPE priority function.
634
+ * WARP priority function.
609
635
  *
610
636
  * Priority = 1 / (1 + bridge_score)
611
- * Bridge score = neighbourhood overlap with other frontiers
612
- * Higher bridge score = more likely to be on paths = explored first
637
+ * Bridge score = cross-frontier neighbour count plus bonus for nodes
638
+ * already on discovered paths.
639
+ * Higher bridge score = more likely to complete paths = explored first.
613
640
  */
614
641
  function warpPriority(nodeId, context) {
615
- const graph = context.graph;
616
- const currentFrontier = context.frontierIndex;
617
- const nodeNeighbours = new Set(graph.neighbours(nodeId));
618
- let bridgeScore = 0;
619
- for (const [visitedId, frontierIdx] of context.visitedByFrontier) if (frontierIdx !== currentFrontier && nodeNeighbours.has(visitedId)) bridgeScore++;
642
+ let bridgeScore = countCrossFrontierNeighbours(context.graph, nodeId, context);
620
643
  for (const path of context.discoveredPaths) if (path.nodes.includes(nodeId)) bridgeScore += 2;
621
644
  return 1 / (1 + bridgeScore);
622
645
  }
@@ -640,24 +663,15 @@ function warp(graph, seeds, config) {
640
663
  //#endregion
641
664
  //#region src/expansion/fuse.ts
642
665
  /**
643
- * SAGE priority function.
666
+ * FUSE priority function.
644
667
  *
645
- * Combines degree with salience:
646
- * Priority = (1 - w) * degree + w * (1 - avg_salience)
647
- * Lower values = higher priority
668
+ * Combines degree with average frontier MI as a salience proxy:
669
+ * Priority = (1 - w) * degree + w * (1 - avgMI)
670
+ * Lower values = higher priority; high salience lowers priority
648
671
  */
649
672
  function fusePriority(nodeId, context, mi, salienceWeight) {
650
- const graph = context.graph;
651
- const degree = context.degree;
652
- const frontierIndex = context.frontierIndex;
653
- let totalSalience = 0;
654
- let count = 0;
655
- for (const [visitedId, idx] of context.visitedByFrontier) if (idx === frontierIndex && visitedId !== nodeId) {
656
- totalSalience += mi(graph, visitedId, nodeId);
657
- count++;
658
- }
659
- const avgSalience = count > 0 ? totalSalience / count : 0;
660
- return (1 - salienceWeight) * degree + salienceWeight * (1 - avgSalience);
673
+ const avgSalience = avgFrontierMI(context.graph, nodeId, context, mi);
674
+ return (1 - salienceWeight) * context.degree + salienceWeight * (1 - avgSalience);
661
675
  }
662
676
  /**
663
677
  * Run FUSE expansion algorithm.
@@ -681,20 +695,13 @@ function fuse(graph, seeds, config) {
681
695
  //#endregion
682
696
  //#region src/expansion/sift.ts
683
697
  /**
684
- * REACH priority function (phase 2).
698
+ * REACH (SIFT) priority function.
685
699
  *
686
- * Uses learned MI threshold to prioritise high-MI edges.
700
+ * Prioritises nodes with average frontier MI above the threshold;
701
+ * falls back to degree-based ordering for those below it.
687
702
  */
688
703
  function siftPriority(nodeId, context, mi, miThreshold) {
689
- const graph = context.graph;
690
- const frontierIndex = context.frontierIndex;
691
- let totalMi = 0;
692
- let count = 0;
693
- for (const [visitedId, idx] of context.visitedByFrontier) if (idx === frontierIndex && visitedId !== nodeId) {
694
- totalMi += mi(graph, visitedId, nodeId);
695
- count++;
696
- }
697
- const avgMi = count > 0 ? totalMi / count : 0;
704
+ const avgMi = avgFrontierMI(context.graph, nodeId, context, mi);
698
705
  if (avgMi >= miThreshold) return 1 - avgMi;
699
706
  else return context.degree + 100;
700
707
  }
@@ -736,16 +743,6 @@ function localDensity(graph, nodeId) {
736
743
  return edges / maxEdges;
737
744
  }
738
745
  /**
739
- * Compute bridge score (how many other frontiers visit neighbours).
740
- */
741
- function bridgeScore(nodeId, context) {
742
- const currentFrontier = context.frontierIndex;
743
- const nodeNeighbours = new Set(context.graph.neighbours(nodeId));
744
- let score = 0;
745
- for (const [visitedId, idx] of context.visitedByFrontier) if (idx !== currentFrontier && nodeNeighbours.has(visitedId)) score++;
746
- return score;
747
- }
748
- /**
749
746
  * MAZE adaptive priority function.
750
747
  *
751
748
  * Switches strategies based on local conditions:
@@ -757,7 +754,7 @@ function fluxPriority(nodeId, context, densityThreshold, bridgeThreshold) {
757
754
  const graph = context.graph;
758
755
  const degree = context.degree;
759
756
  const density = localDensity(graph, nodeId);
760
- const bridge = bridgeScore(nodeId, context);
757
+ const bridge = countCrossFrontierNeighbours(graph, nodeId, context);
761
758
  const numFrontiers = new Set(context.visitedByFrontier.values()).size;
762
759
  if ((numFrontiers > 0 ? bridge / numFrontiers : 0) >= bridgeThreshold) return 1 / (1 + bridge);
763
760
  else if (density >= densityThreshold) return 1 / (degree + 1);
@@ -1406,14 +1403,13 @@ function hubPromoted(graph, source, target, config) {
1406
1403
  */
1407
1404
  function scale(graph, source, target, config) {
1408
1405
  const { epsilon = 1e-10 } = config ?? {};
1409
- const { intersection, union } = require_utils.neighbourOverlap(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
1410
- const jaccard = union > 0 ? intersection / union : 0;
1406
+ const { jaccard: jaccardScore } = require_utils.computeJaccard(graph, source, target);
1411
1407
  const n = graph.nodeCount;
1412
1408
  const m = graph.edgeCount;
1413
1409
  const possibleEdges = n * (n - 1);
1414
1410
  const density = possibleEdges > 0 ? (graph.directed ? m : 2 * m) / possibleEdges : 0;
1415
1411
  if (density === 0) return epsilon;
1416
- const score = jaccard / density;
1412
+ const score = jaccardScore / density;
1417
1413
  return Math.max(epsilon, score);
1418
1414
  }
1419
1415
  //#endregion
@@ -1423,14 +1419,13 @@ function scale(graph, source, target, config) {
1423
1419
  */
1424
1420
  function skew(graph, source, target, config) {
1425
1421
  const { epsilon = 1e-10 } = config ?? {};
1426
- const { intersection, union } = require_utils.neighbourOverlap(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
1427
- const jaccard = union > 0 ? intersection / union : 0;
1422
+ const { jaccard: jaccardScore } = require_utils.computeJaccard(graph, source, target);
1428
1423
  const N = graph.nodeCount;
1429
1424
  const sourceDegree = graph.degree(source);
1430
1425
  const targetDegree = graph.degree(target);
1431
1426
  const sourceIdf = Math.log(N / (sourceDegree + 1));
1432
1427
  const targetIdf = Math.log(N / (targetDegree + 1));
1433
- const score = jaccard * sourceIdf * targetIdf;
1428
+ const score = jaccardScore * sourceIdf * targetIdf;
1434
1429
  return Math.max(epsilon, score);
1435
1430
  }
1436
1431
  //#endregion
@@ -1440,11 +1435,10 @@ function skew(graph, source, target, config) {
1440
1435
  */
1441
1436
  function span(graph, source, target, config) {
1442
1437
  const { epsilon = 1e-10 } = config ?? {};
1443
- const { intersection, union } = require_utils.neighbourOverlap(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
1444
- const jaccard = union > 0 ? intersection / union : 0;
1438
+ const { jaccard: jaccardScore } = require_utils.computeJaccard(graph, source, target);
1445
1439
  const sourceCc = require_utils.localClusteringCoefficient(graph, source);
1446
1440
  const targetCc = require_utils.localClusteringCoefficient(graph, target);
1447
- const score = jaccard * (1 - Math.max(sourceCc, targetCc));
1441
+ const score = jaccardScore * (1 - Math.max(sourceCc, targetCc));
1448
1442
  return Math.max(epsilon, score);
1449
1443
  }
1450
1444
  //#endregion
@@ -1454,13 +1448,12 @@ function span(graph, source, target, config) {
1454
1448
  */
1455
1449
  function etch(graph, source, target, config) {
1456
1450
  const { epsilon = 1e-10 } = config ?? {};
1457
- const { intersection, union } = require_utils.neighbourOverlap(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
1458
- const jaccard = union > 0 ? intersection / union : 0;
1451
+ const { jaccard: jaccardScore } = require_utils.computeJaccard(graph, source, target);
1459
1452
  const edge = graph.getEdge(source, target);
1460
- if (edge?.type === void 0) return Math.max(epsilon, jaccard);
1453
+ if (edge?.type === void 0) return Math.max(epsilon, jaccardScore);
1461
1454
  const edgeTypeCount = require_utils.countEdgesOfType(graph, edge.type);
1462
- if (edgeTypeCount === 0) return Math.max(epsilon, jaccard);
1463
- const score = jaccard * Math.log(graph.edgeCount / edgeTypeCount);
1455
+ if (edgeTypeCount === 0) return Math.max(epsilon, jaccardScore);
1456
+ const score = jaccardScore * Math.log(graph.edgeCount / edgeTypeCount);
1464
1457
  return Math.max(epsilon, score);
1465
1458
  }
1466
1459
  //#endregion
@@ -1470,17 +1463,16 @@ function etch(graph, source, target, config) {
1470
1463
  */
1471
1464
  function notch(graph, source, target, config) {
1472
1465
  const { epsilon = 1e-10 } = config ?? {};
1473
- const { intersection, union } = require_utils.neighbourOverlap(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
1474
- const jaccard = union > 0 ? intersection / union : 0;
1466
+ const { jaccard: jaccardScore } = require_utils.computeJaccard(graph, source, target);
1475
1467
  const sourceNode = graph.getNode(source);
1476
1468
  const targetNode = graph.getNode(target);
1477
- if (sourceNode?.type === void 0 || targetNode?.type === void 0) return Math.max(epsilon, jaccard);
1469
+ if (sourceNode?.type === void 0 || targetNode?.type === void 0) return Math.max(epsilon, jaccardScore);
1478
1470
  const sourceTypeCount = require_utils.countNodesOfType(graph, sourceNode.type);
1479
1471
  const targetTypeCount = require_utils.countNodesOfType(graph, targetNode.type);
1480
- if (sourceTypeCount === 0 || targetTypeCount === 0) return Math.max(epsilon, jaccard);
1472
+ if (sourceTypeCount === 0 || targetTypeCount === 0) return Math.max(epsilon, jaccardScore);
1481
1473
  const sourceRarity = Math.log(graph.nodeCount / sourceTypeCount);
1482
1474
  const targetRarity = Math.log(graph.nodeCount / targetTypeCount);
1483
- const score = jaccard * sourceRarity * targetRarity;
1475
+ const score = jaccardScore * sourceRarity * targetRarity;
1484
1476
  return Math.max(epsilon, score);
1485
1477
  }
1486
1478
  //#endregion
@@ -1499,13 +1491,12 @@ function notch(graph, source, target, config) {
1499
1491
  */
1500
1492
  function adaptive(graph, source, target, config) {
1501
1493
  const { epsilon = 1e-10, structuralWeight = .4, degreeWeight = .3, overlapWeight = .3 } = config ?? {};
1502
- const structural = jaccard(graph, source, target, { epsilon });
1494
+ const { jaccard: jaccardScore, sourceNeighbours, targetNeighbours } = require_utils.computeJaccard(graph, source, target);
1495
+ const structural = sourceNeighbours.size === 0 && targetNeighbours.size === 0 ? 0 : Math.max(epsilon, jaccardScore);
1503
1496
  const degreeComponent = adamicAdar(graph, source, target, {
1504
1497
  epsilon,
1505
1498
  normalise: true
1506
1499
  });
1507
- const sourceNeighbours = require_utils.neighbourSet(graph, source, target);
1508
- const targetNeighbours = require_utils.neighbourSet(graph, target, source);
1509
1500
  let overlap;
1510
1501
  if (sourceNeighbours.size > 0 && targetNeighbours.size > 0) {
1511
1502
  const { intersection } = require_utils.neighbourOverlap(sourceNeighbours, targetNeighbours);
@@ -1517,6 +1508,42 @@ function adaptive(graph, source, target, config) {
1517
1508
  return Math.max(epsilon, Math.min(1, score));
1518
1509
  }
1519
1510
  //#endregion
1511
+ //#region src/ranking/baselines/utils.ts
1512
+ /**
1513
+ * Normalise a set of scored paths and return them sorted highest-first.
1514
+ *
1515
+ * All scores are normalised relative to the maximum observed score.
1516
+ * When `includeScores` is false, raw (un-normalised) scores are preserved.
1517
+ * Handles degenerate cases: empty input and all-zero scores.
1518
+ *
1519
+ * @param paths - Original paths in input order
1520
+ * @param scored - Paths paired with their computed scores
1521
+ * @param method - Method name to embed in the result
1522
+ * @param includeScores - When true, normalise scores to [0, 1]; when false, keep raw scores
1523
+ * @returns BaselineResult with ranked paths
1524
+ */
1525
+ function normaliseAndRank(paths, scored, method, includeScores) {
1526
+ if (scored.length === 0) return {
1527
+ paths: [],
1528
+ method
1529
+ };
1530
+ const maxScore = Math.max(...scored.map((s) => s.score));
1531
+ if (maxScore === 0) return {
1532
+ paths: paths.map((path) => ({
1533
+ ...path,
1534
+ score: 0
1535
+ })),
1536
+ method
1537
+ };
1538
+ return {
1539
+ paths: scored.map(({ path, score }) => ({
1540
+ ...path,
1541
+ score: includeScores ? score / maxScore : score
1542
+ })).sort((a, b) => b.score - a.score),
1543
+ method
1544
+ };
1545
+ }
1546
+ //#endregion
1520
1547
  //#region src/ranking/baselines/shortest.ts
1521
1548
  /**
1522
1549
  * Rank paths by length (shortest first).
@@ -1534,18 +1561,10 @@ function shortest(_graph, paths, config) {
1534
1561
  paths: [],
1535
1562
  method: "shortest"
1536
1563
  };
1537
- const scored = paths.map((path) => ({
1564
+ return normaliseAndRank(paths, paths.map((path) => ({
1538
1565
  path,
1539
1566
  score: 1 / path.nodes.length
1540
- }));
1541
- const maxScore = Math.max(...scored.map((s) => s.score));
1542
- return {
1543
- paths: scored.map(({ path, score }) => ({
1544
- ...path,
1545
- score: includeScores ? score / maxScore : score / maxScore
1546
- })).sort((a, b) => b.score - a.score),
1547
- method: "shortest"
1548
- };
1567
+ })), "shortest", includeScores);
1549
1568
  }
1550
1569
  //#endregion
1551
1570
  //#region src/ranking/baselines/degree-sum.ts
@@ -1563,29 +1582,14 @@ function degreeSum(graph, paths, config) {
1563
1582
  paths: [],
1564
1583
  method: "degree-sum"
1565
1584
  };
1566
- const scored = paths.map((path) => {
1585
+ return normaliseAndRank(paths, paths.map((path) => {
1567
1586
  let degreeSum = 0;
1568
1587
  for (const nodeId of path.nodes) degreeSum += graph.degree(nodeId);
1569
1588
  return {
1570
1589
  path,
1571
1590
  score: degreeSum
1572
1591
  };
1573
- });
1574
- const maxScore = Math.max(...scored.map((s) => s.score));
1575
- if (maxScore === 0) return {
1576
- paths: paths.map((path) => ({
1577
- ...path,
1578
- score: 0
1579
- })),
1580
- method: "degree-sum"
1581
- };
1582
- return {
1583
- paths: scored.map(({ path, score }) => ({
1584
- ...path,
1585
- score: includeScores ? score / maxScore : score
1586
- })).sort((a, b) => b.score - a.score),
1587
- method: "degree-sum"
1588
- };
1592
+ }), "degree-sum", includeScores);
1589
1593
  }
1590
1594
  //#endregion
1591
1595
  //#region src/ranking/baselines/widest-path.ts
@@ -1603,7 +1607,7 @@ function widestPath(graph, paths, config) {
1603
1607
  paths: [],
1604
1608
  method: "widest-path"
1605
1609
  };
1606
- const scored = paths.map((path) => {
1610
+ return normaliseAndRank(paths, paths.map((path) => {
1607
1611
  if (path.nodes.length < 2) return {
1608
1612
  path,
1609
1613
  score: 1
@@ -1620,22 +1624,7 @@ function widestPath(graph, paths, config) {
1620
1624
  path,
1621
1625
  score: minSimilarity === Number.POSITIVE_INFINITY ? 1 : minSimilarity
1622
1626
  };
1623
- });
1624
- const maxScore = Math.max(...scored.map((s) => s.score));
1625
- if (maxScore === 0) return {
1626
- paths: paths.map((path) => ({
1627
- ...path,
1628
- score: 0
1629
- })),
1630
- method: "widest-path"
1631
- };
1632
- return {
1633
- paths: scored.map(({ path, score }) => ({
1634
- ...path,
1635
- score: includeScores ? score / maxScore : score / maxScore
1636
- })).sort((a, b) => b.score - a.score),
1637
- method: "widest-path"
1638
- };
1627
+ }), "widest-path", includeScores);
1639
1628
  }
1640
1629
  //#endregion
1641
1630
  //#region src/ranking/baselines/jaccard-arithmetic.ts
@@ -1653,7 +1642,7 @@ function jaccardArithmetic(graph, paths, config) {
1653
1642
  paths: [],
1654
1643
  method: "jaccard-arithmetic"
1655
1644
  };
1656
- const scored = paths.map((path) => {
1645
+ return normaliseAndRank(paths, paths.map((path) => {
1657
1646
  if (path.nodes.length < 2) return {
1658
1647
  path,
1659
1648
  score: 1
@@ -1672,22 +1661,7 @@ function jaccardArithmetic(graph, paths, config) {
1672
1661
  path,
1673
1662
  score: edgeCount > 0 ? similaritySum / edgeCount : 1
1674
1663
  };
1675
- });
1676
- const maxScore = Math.max(...scored.map((s) => s.score));
1677
- if (maxScore === 0) return {
1678
- paths: paths.map((path) => ({
1679
- ...path,
1680
- score: 0
1681
- })),
1682
- method: "jaccard-arithmetic"
1683
- };
1684
- return {
1685
- paths: scored.map(({ path, score }) => ({
1686
- ...path,
1687
- score: includeScores ? score / maxScore : score
1688
- })).sort((a, b) => b.score - a.score),
1689
- method: "jaccard-arithmetic"
1690
- };
1664
+ }), "jaccard-arithmetic", includeScores);
1691
1665
  }
1692
1666
  //#endregion
1693
1667
  //#region src/ranking/baselines/pagerank.ts
@@ -1748,29 +1722,14 @@ function pagerank(graph, paths, config) {
1748
1722
  method: "pagerank"
1749
1723
  };
1750
1724
  const ranks = computePageRank(graph);
1751
- const scored = paths.map((path) => {
1725
+ return normaliseAndRank(paths, paths.map((path) => {
1752
1726
  let prSum = 0;
1753
1727
  for (const nodeId of path.nodes) prSum += ranks.get(nodeId) ?? 0;
1754
1728
  return {
1755
1729
  path,
1756
1730
  score: prSum
1757
1731
  };
1758
- });
1759
- const maxScore = Math.max(...scored.map((s) => s.score));
1760
- if (maxScore === 0) return {
1761
- paths: paths.map((path) => ({
1762
- ...path,
1763
- score: 0
1764
- })),
1765
- method: "pagerank"
1766
- };
1767
- return {
1768
- paths: scored.map(({ path, score }) => ({
1769
- ...path,
1770
- score: includeScores ? score / maxScore : score / maxScore
1771
- })).sort((a, b) => b.score - a.score),
1772
- method: "pagerank"
1773
- };
1732
+ }), "pagerank", includeScores);
1774
1733
  }
1775
1734
  //#endregion
1776
1735
  //#region src/ranking/baselines/betweenness.ts
@@ -1855,29 +1814,14 @@ function betweenness(graph, paths, config) {
1855
1814
  method: "betweenness"
1856
1815
  };
1857
1816
  const bcMap = computeBetweenness(graph);
1858
- const scored = paths.map((path) => {
1817
+ return normaliseAndRank(paths, paths.map((path) => {
1859
1818
  let bcSum = 0;
1860
1819
  for (const nodeId of path.nodes) bcSum += bcMap.get(nodeId) ?? 0;
1861
1820
  return {
1862
1821
  path,
1863
1822
  score: bcSum
1864
1823
  };
1865
- });
1866
- const maxScore = Math.max(...scored.map((s) => s.score));
1867
- if (maxScore === 0) return {
1868
- paths: paths.map((path) => ({
1869
- ...path,
1870
- score: 0
1871
- })),
1872
- method: "betweenness"
1873
- };
1874
- return {
1875
- paths: scored.map(({ path, score }) => ({
1876
- ...path,
1877
- score: includeScores ? score / maxScore : score / maxScore
1878
- })).sort((a, b) => b.score - a.score),
1879
- method: "betweenness"
1880
- };
1824
+ }), "betweenness", includeScores);
1881
1825
  }
1882
1826
  //#endregion
1883
1827
  //#region src/ranking/baselines/katz.ts
@@ -1940,7 +1884,7 @@ function katz(graph, paths, config) {
1940
1884
  paths: [],
1941
1885
  method: "katz"
1942
1886
  };
1943
- const scored = paths.map((path) => {
1887
+ return normaliseAndRank(paths, paths.map((path) => {
1944
1888
  const source = path.nodes[0];
1945
1889
  const target = path.nodes[path.nodes.length - 1];
1946
1890
  if (source === void 0 || target === void 0) return {
@@ -1951,22 +1895,7 @@ function katz(graph, paths, config) {
1951
1895
  path,
1952
1896
  score: computeKatz(graph, source, target)
1953
1897
  };
1954
- });
1955
- const maxScore = Math.max(...scored.map((s) => s.score));
1956
- if (maxScore === 0) return {
1957
- paths: paths.map((path) => ({
1958
- ...path,
1959
- score: 0
1960
- })),
1961
- method: "katz"
1962
- };
1963
- return {
1964
- paths: scored.map(({ path, score }) => ({
1965
- ...path,
1966
- score: includeScores ? score / maxScore : score / maxScore
1967
- })).sort((a, b) => b.score - a.score),
1968
- method: "katz"
1969
- };
1898
+ }), "katz", includeScores);
1970
1899
  }
1971
1900
  //#endregion
1972
1901
  //#region src/ranking/baselines/communicability.ts
@@ -2028,7 +1957,7 @@ function communicability(graph, paths, config) {
2028
1957
  paths: [],
2029
1958
  method: "communicability"
2030
1959
  };
2031
- const scored = paths.map((path) => {
1960
+ return normaliseAndRank(paths, paths.map((path) => {
2032
1961
  const source = path.nodes[0];
2033
1962
  const target = path.nodes[path.nodes.length - 1];
2034
1963
  if (source === void 0 || target === void 0) return {
@@ -2039,22 +1968,7 @@ function communicability(graph, paths, config) {
2039
1968
  path,
2040
1969
  score: computeCommunicability(graph, source, target)
2041
1970
  };
2042
- });
2043
- const maxScore = Math.max(...scored.map((s) => s.score));
2044
- if (maxScore === 0) return {
2045
- paths: paths.map((path) => ({
2046
- ...path,
2047
- score: 0
2048
- })),
2049
- method: "communicability"
2050
- };
2051
- return {
2052
- paths: scored.map(({ path, score }) => ({
2053
- ...path,
2054
- score: includeScores ? score / maxScore : score / maxScore
2055
- })).sort((a, b) => b.score - a.score),
2056
- method: "communicability"
2057
- };
1971
+ }), "communicability", includeScores);
2058
1972
  }
2059
1973
  //#endregion
2060
1974
  //#region src/ranking/baselines/resistance-distance.ts
@@ -2171,7 +2085,7 @@ function resistanceDistance(graph, paths, config) {
2171
2085
  };
2172
2086
  const nodeCount = Array.from(graph.nodeIds()).length;
2173
2087
  if (nodeCount > 5e3) throw new Error(`Cannot rank paths: graph too large (${String(nodeCount)} nodes). Resistance distance requires O(n^3) computation; maximum 5000 nodes.`);
2174
- const scored = paths.map((path) => {
2088
+ return normaliseAndRank(paths, paths.map((path) => {
2175
2089
  const source = path.nodes[0];
2176
2090
  const target = path.nodes[path.nodes.length - 1];
2177
2091
  if (source === void 0 || target === void 0) return {
@@ -2182,22 +2096,7 @@ function resistanceDistance(graph, paths, config) {
2182
2096
  path,
2183
2097
  score: 1 / computeResistance(graph, source, target)
2184
2098
  };
2185
- });
2186
- const maxScore = Math.max(...scored.map((s) => s.score));
2187
- if (maxScore === 0) return {
2188
- paths: paths.map((path) => ({
2189
- ...path,
2190
- score: 0
2191
- })),
2192
- method: "resistance-distance"
2193
- };
2194
- return {
2195
- paths: scored.map(({ path, score }) => ({
2196
- ...path,
2197
- score: includeScores ? score / maxScore : score / maxScore
2198
- })).sort((a, b) => b.score - a.score),
2199
- method: "resistance-distance"
2200
- };
2099
+ }), "resistance-distance", includeScores);
2201
2100
  }
2202
2101
  //#endregion
2203
2102
  //#region src/ranking/baselines/random-ranking.ts
@@ -2231,27 +2130,12 @@ function randomRanking(_graph, paths, config) {
2231
2130
  paths: [],
2232
2131
  method: "random"
2233
2132
  };
2234
- const scored = paths.map((path) => {
2133
+ return normaliseAndRank(paths, paths.map((path) => {
2235
2134
  return {
2236
2135
  path,
2237
2136
  score: seededRandom(path.nodes.join(","), seed)
2238
2137
  };
2239
- });
2240
- const maxScore = Math.max(...scored.map((s) => s.score));
2241
- if (maxScore === 0) return {
2242
- paths: paths.map((path) => ({
2243
- ...path,
2244
- score: 0
2245
- })),
2246
- method: "random"
2247
- };
2248
- return {
2249
- paths: scored.map(({ path, score }) => ({
2250
- ...path,
2251
- score: includeScores ? score / maxScore : score / maxScore
2252
- })).sort((a, b) => b.score - a.score),
2253
- method: "random"
2254
- };
2138
+ }), "random", includeScores);
2255
2139
  }
2256
2140
  //#endregion
2257
2141
  //#region src/ranking/baselines/hitting-time.ts
@@ -2490,20 +2374,14 @@ function hittingTime(graph, paths, config) {
2490
2374
  };
2491
2375
  });
2492
2376
  const maxScore = Math.max(...scored.map((s) => s.score));
2493
- if (maxScore === 0 || !Number.isFinite(maxScore)) return {
2377
+ if (!Number.isFinite(maxScore)) return {
2494
2378
  paths: paths.map((path) => ({
2495
2379
  ...path,
2496
2380
  score: 0
2497
2381
  })),
2498
2382
  method: "hitting-time"
2499
2383
  };
2500
- return {
2501
- paths: scored.map(({ path, score }) => ({
2502
- ...path,
2503
- score: includeScores ? score / maxScore : score / maxScore
2504
- })).sort((a, b) => b.score - a.score),
2505
- method: "hitting-time"
2506
- };
2384
+ return normaliseAndRank(paths, scored, "hitting-time", includeScores);
2507
2385
  }
2508
2386
  //#endregion
2509
2387
  //#region src/extraction/ego-network.ts
@@ -3138,6 +3016,7 @@ exports.betweenness = betweenness;
3138
3016
  exports.bfs = require_traversal.bfs;
3139
3017
  exports.bfsWithPath = require_traversal.bfsWithPath;
3140
3018
  exports.communicability = communicability;
3019
+ exports.computeJaccard = require_utils.computeJaccard;
3141
3020
  exports.computeTrussNumbers = computeTrussNumbers;
3142
3021
  exports.cosine = cosine;
3143
3022
  exports.countEdgesOfType = require_utils.countEdgesOfType;