graphwise 1.4.3 → 1.5.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 (97) 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/__test__/fixtures/index.d.ts +1 -0
  8. package/dist/__test__/fixtures/index.d.ts.map +1 -1
  9. package/dist/__test__/fixtures/metrics.d.ts +86 -0
  10. package/dist/__test__/fixtures/metrics.d.ts.map +1 -0
  11. package/dist/__test__/fixtures/metrics.unit.test.d.ts +7 -0
  12. package/dist/__test__/fixtures/metrics.unit.test.d.ts.map +1 -0
  13. package/dist/expansion/dfs-priority.d.ts +23 -0
  14. package/dist/expansion/dfs-priority.d.ts.map +1 -0
  15. package/dist/expansion/dfs-priority.unit.test.d.ts +2 -0
  16. package/dist/expansion/dfs-priority.unit.test.d.ts.map +1 -0
  17. package/dist/expansion/edge.d.ts.map +1 -1
  18. package/dist/expansion/flux.d.ts.map +1 -1
  19. package/dist/expansion/fuse.d.ts.map +1 -1
  20. package/dist/expansion/index.d.ts +3 -0
  21. package/dist/expansion/index.d.ts.map +1 -1
  22. package/dist/expansion/k-hop.d.ts +26 -0
  23. package/dist/expansion/k-hop.d.ts.map +1 -0
  24. package/dist/expansion/k-hop.unit.test.d.ts +2 -0
  25. package/dist/expansion/k-hop.unit.test.d.ts.map +1 -0
  26. package/dist/expansion/lace.d.ts.map +1 -1
  27. package/dist/expansion/maze.d.ts.map +1 -1
  28. package/dist/expansion/priority-helpers.d.ts +43 -0
  29. package/dist/expansion/priority-helpers.d.ts.map +1 -0
  30. package/dist/expansion/random-walk.d.ts +35 -0
  31. package/dist/expansion/random-walk.d.ts.map +1 -0
  32. package/dist/expansion/random-walk.unit.test.d.ts +2 -0
  33. package/dist/expansion/random-walk.unit.test.d.ts.map +1 -0
  34. package/dist/expansion/sage.d.ts.map +1 -1
  35. package/dist/expansion/sift.d.ts.map +1 -1
  36. package/dist/expansion/warp.d.ts.map +1 -1
  37. package/dist/index/index.cjs +887 -297
  38. package/dist/index/index.cjs.map +1 -1
  39. package/dist/index/index.js +878 -299
  40. package/dist/index/index.js.map +1 -1
  41. package/dist/ranking/baselines/betweenness.d.ts.map +1 -1
  42. package/dist/ranking/baselines/communicability.d.ts.map +1 -1
  43. package/dist/ranking/baselines/degree-sum.d.ts.map +1 -1
  44. package/dist/ranking/baselines/hitting-time.d.ts +27 -0
  45. package/dist/ranking/baselines/hitting-time.d.ts.map +1 -0
  46. package/dist/ranking/baselines/hitting-time.unit.test.d.ts +2 -0
  47. package/dist/ranking/baselines/hitting-time.unit.test.d.ts.map +1 -0
  48. package/dist/ranking/baselines/index.d.ts +1 -0
  49. package/dist/ranking/baselines/index.d.ts.map +1 -1
  50. package/dist/ranking/baselines/jaccard-arithmetic.d.ts.map +1 -1
  51. package/dist/ranking/baselines/katz.d.ts.map +1 -1
  52. package/dist/ranking/baselines/pagerank.d.ts.map +1 -1
  53. package/dist/ranking/baselines/random-ranking.d.ts.map +1 -1
  54. package/dist/ranking/baselines/resistance-distance.d.ts.map +1 -1
  55. package/dist/ranking/baselines/shortest.d.ts.map +1 -1
  56. package/dist/ranking/baselines/utils.d.ts +20 -0
  57. package/dist/ranking/baselines/utils.d.ts.map +1 -0
  58. package/dist/ranking/baselines/widest-path.d.ts.map +1 -1
  59. package/dist/ranking/mi/adaptive.d.ts.map +1 -1
  60. package/dist/ranking/mi/cosine.d.ts +13 -0
  61. package/dist/ranking/mi/cosine.d.ts.map +1 -0
  62. package/dist/ranking/mi/cosine.unit.test.d.ts +2 -0
  63. package/dist/ranking/mi/cosine.unit.test.d.ts.map +1 -0
  64. package/dist/ranking/mi/etch.d.ts.map +1 -1
  65. package/dist/ranking/mi/hub-promoted.d.ts +13 -0
  66. package/dist/ranking/mi/hub-promoted.d.ts.map +1 -0
  67. package/dist/ranking/mi/hub-promoted.unit.test.d.ts +2 -0
  68. package/dist/ranking/mi/hub-promoted.unit.test.d.ts.map +1 -0
  69. package/dist/ranking/mi/index.d.ts +5 -0
  70. package/dist/ranking/mi/index.d.ts.map +1 -1
  71. package/dist/ranking/mi/jaccard.d.ts.map +1 -1
  72. package/dist/ranking/mi/notch.d.ts.map +1 -1
  73. package/dist/ranking/mi/overlap-coefficient.d.ts +13 -0
  74. package/dist/ranking/mi/overlap-coefficient.d.ts.map +1 -0
  75. package/dist/ranking/mi/overlap-coefficient.unit.test.d.ts +2 -0
  76. package/dist/ranking/mi/overlap-coefficient.unit.test.d.ts.map +1 -0
  77. package/dist/ranking/mi/resource-allocation.d.ts +13 -0
  78. package/dist/ranking/mi/resource-allocation.d.ts.map +1 -0
  79. package/dist/ranking/mi/resource-allocation.unit.test.d.ts +2 -0
  80. package/dist/ranking/mi/resource-allocation.unit.test.d.ts.map +1 -0
  81. package/dist/ranking/mi/scale.d.ts.map +1 -1
  82. package/dist/ranking/mi/skew.d.ts.map +1 -1
  83. package/dist/ranking/mi/sorensen.d.ts +13 -0
  84. package/dist/ranking/mi/sorensen.d.ts.map +1 -0
  85. package/dist/ranking/mi/sorensen.unit.test.d.ts +2 -0
  86. package/dist/ranking/mi/sorensen.unit.test.d.ts.map +1 -0
  87. package/dist/ranking/mi/span.d.ts.map +1 -1
  88. package/dist/ranking/mi/types.d.ts +1 -1
  89. package/dist/ranking/mi/types.d.ts.map +1 -1
  90. package/dist/schemas/graph.d.ts +1 -1
  91. package/dist/utils/index.cjs +22 -0
  92. package/dist/utils/index.cjs.map +1 -1
  93. package/dist/utils/index.js +22 -1
  94. package/dist/utils/index.js.map +1 -1
  95. package/dist/utils/neighbours.d.ts +23 -0
  96. package/dist/utils/neighbours.d.ts.map +1 -1
  97. package/package.json +1 -1
@@ -24,7 +24,7 @@ function degreePriority(_nodeId, context) {
24
24
  function base(graph, seeds, config) {
25
25
  const startTime = performance.now();
26
26
  const { maxNodes = 0, maxIterations = 0, maxPaths = 0, priority = degreePriority, debug = false } = config ?? {};
27
- if (seeds.length === 0) return emptyResult("base", startTime);
27
+ if (seeds.length === 0) return emptyResult$2("base", startTime);
28
28
  const numFrontiers = seeds.length;
29
29
  const allVisited = /* @__PURE__ */ new Set();
30
30
  const combinedVisited = /* @__PURE__ */ new Map();
@@ -105,7 +105,7 @@ function base(graph, seeds, config) {
105
105
  const otherVisited = visitedByFrontier[otherFrontier];
106
106
  if (otherVisited === void 0) continue;
107
107
  if (otherVisited.has(nodeId)) {
108
- const path = reconstructPath(nodeId, activeFrontier, otherFrontier, predecessors, seeds);
108
+ const path = reconstructPath$1(nodeId, activeFrontier, otherFrontier, predecessors, seeds);
109
109
  if (path !== null) {
110
110
  discoveredPaths.push(path);
111
111
  if (debug) console.log(`[BASE] Path found: ${path.nodes.join(" -> ")}`);
@@ -170,7 +170,7 @@ function createPriorityContext(graph, nodeId, frontierIndex, combinedVisited, al
170
170
  /**
171
171
  * Reconstruct path from collision point.
172
172
  */
173
- function reconstructPath(collisionNode, frontierA, frontierB, predecessors, seeds) {
173
+ function reconstructPath$1(collisionNode, frontierA, frontierB, predecessors, seeds) {
174
174
  const pathA = [collisionNode];
175
175
  const predA = predecessors[frontierA];
176
176
  if (predA !== void 0) {
@@ -206,7 +206,7 @@ function reconstructPath(collisionNode, frontierA, frontierB, predecessors, seed
206
206
  /**
207
207
  * Create an empty result for early termination.
208
208
  */
209
- function emptyResult(algorithm, startTime) {
209
+ function emptyResult$2(algorithm, startTime) {
210
210
  return {
211
211
  paths: [],
212
212
  sampledNodes: /* @__PURE__ */ new Set(),
@@ -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,10 +754,10 @@ 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
- else if (density >= densityThreshold) return -degree;
760
+ else if (density >= densityThreshold) return 1 / (degree + 1);
764
761
  else return degree;
765
762
  }
766
763
  /**
@@ -858,6 +855,346 @@ function randomPriority(graph, seeds, config) {
858
855
  });
859
856
  }
860
857
  //#endregion
858
+ //#region src/expansion/dfs-priority.ts
859
+ /**
860
+ * DFS priority function: negative iteration produces LIFO ordering.
861
+ *
862
+ * Lower priority values are expanded first, so negating the iteration
863
+ * counter ensures the most recently enqueued node is always next.
864
+ */
865
+ function dfsPriorityFn(_nodeId, context) {
866
+ return -context.iteration;
867
+ }
868
+ /**
869
+ * Run DFS-priority expansion (LIFO discovery order).
870
+ *
871
+ * Uses the BASE framework with a negative-iteration priority function,
872
+ * which causes the most recently discovered node to be expanded first —
873
+ * equivalent to depth-first search behaviour.
874
+ *
875
+ * @param graph - Source graph
876
+ * @param seeds - Seed nodes for expansion
877
+ * @param config - Expansion configuration
878
+ * @returns Expansion result with discovered paths
879
+ */
880
+ function dfsPriority(graph, seeds, config) {
881
+ return base(graph, seeds, {
882
+ ...config,
883
+ priority: dfsPriorityFn
884
+ });
885
+ }
886
+ //#endregion
887
+ //#region src/expansion/k-hop.ts
888
+ /**
889
+ * Run k-hop expansion (fixed-depth BFS).
890
+ *
891
+ * Explores all nodes reachable within exactly k hops of any seed using
892
+ * breadth-first search. Paths between seeds are detected when a node
893
+ * is reached by frontiers from two different seeds.
894
+ *
895
+ * @param graph - Source graph
896
+ * @param seeds - Seed nodes for expansion
897
+ * @param config - K-hop configuration (k defaults to 2)
898
+ * @returns Expansion result with discovered paths
899
+ */
900
+ function kHop(graph, seeds, config) {
901
+ const startTime = performance.now();
902
+ const { k = 2 } = config ?? {};
903
+ if (seeds.length === 0) return emptyResult$1(startTime);
904
+ const visitedByFrontier = seeds.map(() => /* @__PURE__ */ new Map());
905
+ const firstVisitedBy = /* @__PURE__ */ new Map();
906
+ const allVisited = /* @__PURE__ */ new Set();
907
+ const sampledEdgeMap = /* @__PURE__ */ new Map();
908
+ const discoveredPaths = [];
909
+ let iterations = 0;
910
+ let edgesTraversed = 0;
911
+ for (let i = 0; i < seeds.length; i++) {
912
+ const seed = seeds[i];
913
+ if (seed === void 0) continue;
914
+ if (!graph.hasNode(seed.id)) continue;
915
+ visitedByFrontier[i]?.set(seed.id, null);
916
+ allVisited.add(seed.id);
917
+ if (!firstVisitedBy.has(seed.id)) firstVisitedBy.set(seed.id, i);
918
+ else {
919
+ const otherIdx = firstVisitedBy.get(seed.id) ?? -1;
920
+ if (otherIdx < 0) continue;
921
+ const fromSeed = seeds[otherIdx];
922
+ const toSeed = seeds[i];
923
+ if (fromSeed !== void 0 && toSeed !== void 0) discoveredPaths.push({
924
+ fromSeed,
925
+ toSeed,
926
+ nodes: [seed.id]
927
+ });
928
+ }
929
+ }
930
+ let currentLevel = seeds.map((s, i) => {
931
+ const frontier = visitedByFrontier[i];
932
+ if (frontier === void 0) return [];
933
+ return frontier.has(s.id) ? [s.id] : [];
934
+ });
935
+ for (let hop = 0; hop < k; hop++) {
936
+ const nextLevel = seeds.map(() => []);
937
+ for (let i = 0; i < seeds.length; i++) {
938
+ const level = currentLevel[i];
939
+ if (level === void 0) continue;
940
+ const frontierVisited = visitedByFrontier[i];
941
+ if (frontierVisited === void 0) continue;
942
+ for (const nodeId of level) {
943
+ iterations++;
944
+ for (const neighbour of graph.neighbours(nodeId)) {
945
+ edgesTraversed++;
946
+ const [s, t] = nodeId < neighbour ? [nodeId, neighbour] : [neighbour, nodeId];
947
+ let targets = sampledEdgeMap.get(s);
948
+ if (targets === void 0) {
949
+ targets = /* @__PURE__ */ new Set();
950
+ sampledEdgeMap.set(s, targets);
951
+ }
952
+ targets.add(t);
953
+ if (frontierVisited.has(neighbour)) continue;
954
+ frontierVisited.set(neighbour, nodeId);
955
+ allVisited.add(neighbour);
956
+ nextLevel[i]?.push(neighbour);
957
+ const previousFrontier = firstVisitedBy.get(neighbour);
958
+ if (previousFrontier !== void 0 && previousFrontier !== i) {
959
+ const fromSeed = seeds[previousFrontier];
960
+ const toSeed = seeds[i];
961
+ if (fromSeed !== void 0 && toSeed !== void 0) {
962
+ const path = reconstructPath(neighbour, previousFrontier, i, visitedByFrontier, seeds);
963
+ if (path !== null) {
964
+ if (!discoveredPaths.some((p) => p.fromSeed.id === fromSeed.id && p.toSeed.id === toSeed.id || p.fromSeed.id === toSeed.id && p.toSeed.id === fromSeed.id)) discoveredPaths.push(path);
965
+ }
966
+ }
967
+ }
968
+ if (!firstVisitedBy.has(neighbour)) firstVisitedBy.set(neighbour, i);
969
+ }
970
+ }
971
+ }
972
+ currentLevel = nextLevel;
973
+ if (currentLevel.every((level) => level.length === 0)) break;
974
+ }
975
+ const endTime = performance.now();
976
+ const edgeTuples = /* @__PURE__ */ new Set();
977
+ for (const [source, targets] of sampledEdgeMap) for (const target of targets) edgeTuples.add([source, target]);
978
+ return {
979
+ paths: discoveredPaths,
980
+ sampledNodes: allVisited,
981
+ sampledEdges: edgeTuples,
982
+ visitedPerFrontier: visitedByFrontier.map((m) => new Set(m.keys())),
983
+ stats: {
984
+ iterations,
985
+ nodesVisited: allVisited.size,
986
+ edgesTraversed,
987
+ pathsFound: discoveredPaths.length,
988
+ durationMs: endTime - startTime,
989
+ algorithm: "k-hop",
990
+ termination: "exhausted"
991
+ }
992
+ };
993
+ }
994
+ /**
995
+ * Reconstruct the path between two colliding frontiers.
996
+ */
997
+ function reconstructPath(collisionNode, frontierA, frontierB, visitedByFrontier, seeds) {
998
+ const seedA = seeds[frontierA];
999
+ const seedB = seeds[frontierB];
1000
+ if (seedA === void 0 || seedB === void 0) return null;
1001
+ const pathA = [collisionNode];
1002
+ const predA = visitedByFrontier[frontierA];
1003
+ if (predA !== void 0) {
1004
+ let node = collisionNode;
1005
+ let pred = predA.get(node);
1006
+ while (pred !== null && pred !== void 0) {
1007
+ pathA.unshift(pred);
1008
+ node = pred;
1009
+ pred = predA.get(node);
1010
+ }
1011
+ }
1012
+ const pathB = [];
1013
+ const predB = visitedByFrontier[frontierB];
1014
+ if (predB !== void 0) {
1015
+ let node = collisionNode;
1016
+ let pred = predB.get(node);
1017
+ while (pred !== null && pred !== void 0) {
1018
+ pathB.push(pred);
1019
+ node = pred;
1020
+ pred = predB.get(node);
1021
+ }
1022
+ }
1023
+ return {
1024
+ fromSeed: seedA,
1025
+ toSeed: seedB,
1026
+ nodes: [...pathA, ...pathB]
1027
+ };
1028
+ }
1029
+ /**
1030
+ * Create an empty result for early termination (no seeds).
1031
+ */
1032
+ function emptyResult$1(startTime) {
1033
+ return {
1034
+ paths: [],
1035
+ sampledNodes: /* @__PURE__ */ new Set(),
1036
+ sampledEdges: /* @__PURE__ */ new Set(),
1037
+ visitedPerFrontier: [],
1038
+ stats: {
1039
+ iterations: 0,
1040
+ nodesVisited: 0,
1041
+ edgesTraversed: 0,
1042
+ pathsFound: 0,
1043
+ durationMs: performance.now() - startTime,
1044
+ algorithm: "k-hop",
1045
+ termination: "exhausted"
1046
+ }
1047
+ };
1048
+ }
1049
+ //#endregion
1050
+ //#region src/expansion/random-walk.ts
1051
+ /**
1052
+ * Mulberry32 seeded PRNG — fast, compact, and high-quality for simulation.
1053
+ *
1054
+ * Returns a closure that yields the next pseudo-random value in [0, 1)
1055
+ * on each call.
1056
+ *
1057
+ * @param seed - 32-bit integer seed
1058
+ */
1059
+ function mulberry32(seed) {
1060
+ let s = seed;
1061
+ return () => {
1062
+ s += 1831565813;
1063
+ let t = s;
1064
+ t = Math.imul(t ^ t >>> 15, t | 1);
1065
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
1066
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1067
+ };
1068
+ }
1069
+ /**
1070
+ * Run random-walk-with-restart expansion.
1071
+ *
1072
+ * For each seed, performs `walks` independent random walks of up to
1073
+ * `walkLength` steps. At each step the walk either restarts (with
1074
+ * probability `restartProbability`) or moves to a uniformly sampled
1075
+ * neighbour. All visited nodes and traversed edges are collected.
1076
+ *
1077
+ * Inter-seed paths are detected when a walk reaches a node that was
1078
+ * previously reached by a walk originating from a different seed.
1079
+ * The recorded path contains only the two seed endpoints rather than
1080
+ * the full walk trajectory, consistent with the ExpansionPath contract.
1081
+ *
1082
+ * @param graph - Source graph
1083
+ * @param seeds - Seed nodes for expansion
1084
+ * @param config - Random walk configuration
1085
+ * @returns Expansion result with discovered paths
1086
+ */
1087
+ function randomWalk(graph, seeds, config) {
1088
+ const startTime = performance.now();
1089
+ const { restartProbability = .15, walks = 10, walkLength = 20, seed = 0 } = config ?? {};
1090
+ if (seeds.length === 0) return emptyResult(startTime);
1091
+ const rand = mulberry32(seed);
1092
+ const firstVisitedBySeed = /* @__PURE__ */ new Map();
1093
+ const allVisited = /* @__PURE__ */ new Set();
1094
+ const sampledEdgeMap = /* @__PURE__ */ new Map();
1095
+ const discoveredPaths = [];
1096
+ let iterations = 0;
1097
+ let edgesTraversed = 0;
1098
+ const visitedPerFrontier = seeds.map(() => /* @__PURE__ */ new Set());
1099
+ for (let seedIdx = 0; seedIdx < seeds.length; seedIdx++) {
1100
+ const seed_ = seeds[seedIdx];
1101
+ if (seed_ === void 0) continue;
1102
+ const seedId = seed_.id;
1103
+ if (!graph.hasNode(seedId)) continue;
1104
+ if (!firstVisitedBySeed.has(seedId)) firstVisitedBySeed.set(seedId, seedIdx);
1105
+ allVisited.add(seedId);
1106
+ visitedPerFrontier[seedIdx]?.add(seedId);
1107
+ for (let w = 0; w < walks; w++) {
1108
+ let current = seedId;
1109
+ for (let step = 0; step < walkLength; step++) {
1110
+ iterations++;
1111
+ if (rand() < restartProbability) {
1112
+ current = seedId;
1113
+ continue;
1114
+ }
1115
+ const neighbourList = [];
1116
+ for (const nb of graph.neighbours(current)) neighbourList.push(nb);
1117
+ if (neighbourList.length === 0) {
1118
+ current = seedId;
1119
+ continue;
1120
+ }
1121
+ const next = neighbourList[Math.floor(rand() * neighbourList.length)];
1122
+ if (next === void 0) {
1123
+ current = seedId;
1124
+ continue;
1125
+ }
1126
+ edgesTraversed++;
1127
+ const [s, t] = current < next ? [current, next] : [next, current];
1128
+ let targets = sampledEdgeMap.get(s);
1129
+ if (targets === void 0) {
1130
+ targets = /* @__PURE__ */ new Set();
1131
+ sampledEdgeMap.set(s, targets);
1132
+ }
1133
+ targets.add(t);
1134
+ const previousSeedIdx = firstVisitedBySeed.get(next);
1135
+ if (previousSeedIdx !== void 0 && previousSeedIdx !== seedIdx) {
1136
+ const fromSeed = seeds[previousSeedIdx];
1137
+ const toSeed = seeds[seedIdx];
1138
+ if (fromSeed !== void 0 && toSeed !== void 0) {
1139
+ const path = {
1140
+ fromSeed,
1141
+ toSeed,
1142
+ nodes: [
1143
+ fromSeed.id,
1144
+ next,
1145
+ toSeed.id
1146
+ ].filter((n, i, arr) => arr.indexOf(n) === i)
1147
+ };
1148
+ if (!discoveredPaths.some((p) => p.fromSeed.id === fromSeed.id && p.toSeed.id === toSeed.id || p.fromSeed.id === toSeed.id && p.toSeed.id === fromSeed.id)) discoveredPaths.push(path);
1149
+ }
1150
+ }
1151
+ if (!firstVisitedBySeed.has(next)) firstVisitedBySeed.set(next, seedIdx);
1152
+ allVisited.add(next);
1153
+ visitedPerFrontier[seedIdx]?.add(next);
1154
+ current = next;
1155
+ }
1156
+ }
1157
+ }
1158
+ const endTime = performance.now();
1159
+ const edgeTuples = /* @__PURE__ */ new Set();
1160
+ for (const [source, targets] of sampledEdgeMap) for (const target of targets) edgeTuples.add([source, target]);
1161
+ return {
1162
+ paths: discoveredPaths,
1163
+ sampledNodes: allVisited,
1164
+ sampledEdges: edgeTuples,
1165
+ visitedPerFrontier,
1166
+ stats: {
1167
+ iterations,
1168
+ nodesVisited: allVisited.size,
1169
+ edgesTraversed,
1170
+ pathsFound: discoveredPaths.length,
1171
+ durationMs: endTime - startTime,
1172
+ algorithm: "random-walk",
1173
+ termination: "exhausted"
1174
+ }
1175
+ };
1176
+ }
1177
+ /**
1178
+ * Create an empty result for early termination (no seeds).
1179
+ */
1180
+ function emptyResult(startTime) {
1181
+ return {
1182
+ paths: [],
1183
+ sampledNodes: /* @__PURE__ */ new Set(),
1184
+ sampledEdges: /* @__PURE__ */ new Set(),
1185
+ visitedPerFrontier: [],
1186
+ stats: {
1187
+ iterations: 0,
1188
+ nodesVisited: 0,
1189
+ edgesTraversed: 0,
1190
+ pathsFound: 0,
1191
+ durationMs: performance.now() - startTime,
1192
+ algorithm: "random-walk",
1193
+ termination: "exhausted"
1194
+ }
1195
+ };
1196
+ }
1197
+ //#endregion
861
1198
  //#region src/ranking/parse.ts
862
1199
  /**
863
1200
  * Rank paths using PARSE (Path-Aware Ranking via Salience Estimation).
@@ -951,20 +1288,128 @@ function adamicAdar(graph, source, target, config) {
951
1288
  return Math.max(epsilon, score);
952
1289
  }
953
1290
  //#endregion
1291
+ //#region src/ranking/mi/cosine.ts
1292
+ /**
1293
+ * Compute cosine similarity between neighbourhoods of two nodes.
1294
+ *
1295
+ * @param graph - Source graph
1296
+ * @param source - Source node ID
1297
+ * @param target - Target node ID
1298
+ * @param config - Optional configuration
1299
+ * @returns Cosine similarity in [0, 1]
1300
+ */
1301
+ function cosine(graph, source, target, config) {
1302
+ const { epsilon = 1e-10 } = config ?? {};
1303
+ const sourceNeighbours = require_utils.neighbourSet(graph, source, target);
1304
+ const targetNeighbours = require_utils.neighbourSet(graph, target, source);
1305
+ const { intersection } = require_utils.neighbourOverlap(sourceNeighbours, targetNeighbours);
1306
+ const denominator = Math.sqrt(sourceNeighbours.size) * Math.sqrt(targetNeighbours.size);
1307
+ if (denominator === 0) return 0;
1308
+ const score = intersection / denominator;
1309
+ return Math.max(epsilon, score);
1310
+ }
1311
+ //#endregion
1312
+ //#region src/ranking/mi/sorensen.ts
1313
+ /**
1314
+ * Compute Sorensen-Dice similarity between neighbourhoods of two nodes.
1315
+ *
1316
+ * @param graph - Source graph
1317
+ * @param source - Source node ID
1318
+ * @param target - Target node ID
1319
+ * @param config - Optional configuration
1320
+ * @returns Sorensen-Dice coefficient in [0, 1]
1321
+ */
1322
+ function sorensen(graph, source, target, config) {
1323
+ const { epsilon = 1e-10 } = config ?? {};
1324
+ const sourceNeighbours = require_utils.neighbourSet(graph, source, target);
1325
+ const targetNeighbours = require_utils.neighbourSet(graph, target, source);
1326
+ const { intersection } = require_utils.neighbourOverlap(sourceNeighbours, targetNeighbours);
1327
+ const denominator = sourceNeighbours.size + targetNeighbours.size;
1328
+ if (denominator === 0) return 0;
1329
+ const score = 2 * intersection / denominator;
1330
+ return Math.max(epsilon, score);
1331
+ }
1332
+ //#endregion
1333
+ //#region src/ranking/mi/resource-allocation.ts
1334
+ /**
1335
+ * Compute Resource Allocation index between neighbourhoods of two nodes.
1336
+ *
1337
+ * @param graph - Source graph
1338
+ * @param source - Source node ID
1339
+ * @param target - Target node ID
1340
+ * @param config - Optional configuration
1341
+ * @returns Resource Allocation index (normalised to [0, 1] if configured)
1342
+ */
1343
+ function resourceAllocation(graph, source, target, config) {
1344
+ const { epsilon = 1e-10, normalise = true } = config ?? {};
1345
+ const commonNeighbours = require_utils.neighbourIntersection(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
1346
+ let score = 0;
1347
+ for (const neighbour of commonNeighbours) {
1348
+ const degree = graph.degree(neighbour);
1349
+ if (degree > 0) score += 1 / degree;
1350
+ }
1351
+ if (normalise && commonNeighbours.size > 0) {
1352
+ const maxScore = commonNeighbours.size;
1353
+ score = score / maxScore;
1354
+ }
1355
+ return Math.max(epsilon, score);
1356
+ }
1357
+ //#endregion
1358
+ //#region src/ranking/mi/overlap-coefficient.ts
1359
+ /**
1360
+ * Compute Overlap Coefficient between neighbourhoods of two nodes.
1361
+ *
1362
+ * @param graph - Source graph
1363
+ * @param source - Source node ID
1364
+ * @param target - Target node ID
1365
+ * @param config - Optional configuration
1366
+ * @returns Overlap Coefficient in [0, 1]
1367
+ */
1368
+ function overlapCoefficient(graph, source, target, config) {
1369
+ const { epsilon = 1e-10 } = config ?? {};
1370
+ const sourceNeighbours = require_utils.neighbourSet(graph, source, target);
1371
+ const targetNeighbours = require_utils.neighbourSet(graph, target, source);
1372
+ const { intersection } = require_utils.neighbourOverlap(sourceNeighbours, targetNeighbours);
1373
+ const denominator = Math.min(sourceNeighbours.size, targetNeighbours.size);
1374
+ if (denominator === 0) return 0;
1375
+ const score = intersection / denominator;
1376
+ return Math.max(epsilon, score);
1377
+ }
1378
+ //#endregion
1379
+ //#region src/ranking/mi/hub-promoted.ts
1380
+ /**
1381
+ * Compute Hub Promoted index between neighbourhoods of two nodes.
1382
+ *
1383
+ * @param graph - Source graph
1384
+ * @param source - Source node ID
1385
+ * @param target - Target node ID
1386
+ * @param config - Optional configuration
1387
+ * @returns Hub Promoted index in [0, 1]
1388
+ */
1389
+ function hubPromoted(graph, source, target, config) {
1390
+ const { epsilon = 1e-10 } = config ?? {};
1391
+ const { intersection } = require_utils.neighbourOverlap(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
1392
+ const sourceDegree = graph.degree(source);
1393
+ const targetDegree = graph.degree(target);
1394
+ const denominator = Math.min(sourceDegree, targetDegree);
1395
+ if (denominator === 0) return 0;
1396
+ const score = intersection / denominator;
1397
+ return Math.max(epsilon, score);
1398
+ }
1399
+ //#endregion
954
1400
  //#region src/ranking/mi/scale.ts
955
1401
  /**
956
1402
  * Compute SCALE MI between two nodes.
957
1403
  */
958
1404
  function scale(graph, source, target, config) {
959
1405
  const { epsilon = 1e-10 } = config ?? {};
960
- const { intersection, union } = require_utils.neighbourOverlap(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
961
- const jaccard = union > 0 ? intersection / union : 0;
1406
+ const { jaccard: jaccardScore } = require_utils.computeJaccard(graph, source, target);
962
1407
  const n = graph.nodeCount;
963
1408
  const m = graph.edgeCount;
964
1409
  const possibleEdges = n * (n - 1);
965
1410
  const density = possibleEdges > 0 ? (graph.directed ? m : 2 * m) / possibleEdges : 0;
966
1411
  if (density === 0) return epsilon;
967
- const score = jaccard / density;
1412
+ const score = jaccardScore / density;
968
1413
  return Math.max(epsilon, score);
969
1414
  }
970
1415
  //#endregion
@@ -974,14 +1419,13 @@ function scale(graph, source, target, config) {
974
1419
  */
975
1420
  function skew(graph, source, target, config) {
976
1421
  const { epsilon = 1e-10 } = config ?? {};
977
- const { intersection, union } = require_utils.neighbourOverlap(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
978
- const jaccard = union > 0 ? intersection / union : 0;
1422
+ const { jaccard: jaccardScore } = require_utils.computeJaccard(graph, source, target);
979
1423
  const N = graph.nodeCount;
980
1424
  const sourceDegree = graph.degree(source);
981
1425
  const targetDegree = graph.degree(target);
982
1426
  const sourceIdf = Math.log(N / (sourceDegree + 1));
983
1427
  const targetIdf = Math.log(N / (targetDegree + 1));
984
- const score = jaccard * sourceIdf * targetIdf;
1428
+ const score = jaccardScore * sourceIdf * targetIdf;
985
1429
  return Math.max(epsilon, score);
986
1430
  }
987
1431
  //#endregion
@@ -991,11 +1435,10 @@ function skew(graph, source, target, config) {
991
1435
  */
992
1436
  function span(graph, source, target, config) {
993
1437
  const { epsilon = 1e-10 } = config ?? {};
994
- const { intersection, union } = require_utils.neighbourOverlap(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
995
- const jaccard = union > 0 ? intersection / union : 0;
1438
+ const { jaccard: jaccardScore } = require_utils.computeJaccard(graph, source, target);
996
1439
  const sourceCc = require_utils.localClusteringCoefficient(graph, source);
997
1440
  const targetCc = require_utils.localClusteringCoefficient(graph, target);
998
- const score = jaccard * (1 - Math.max(sourceCc, targetCc));
1441
+ const score = jaccardScore * (1 - Math.max(sourceCc, targetCc));
999
1442
  return Math.max(epsilon, score);
1000
1443
  }
1001
1444
  //#endregion
@@ -1005,13 +1448,12 @@ function span(graph, source, target, config) {
1005
1448
  */
1006
1449
  function etch(graph, source, target, config) {
1007
1450
  const { epsilon = 1e-10 } = config ?? {};
1008
- const { intersection, union } = require_utils.neighbourOverlap(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
1009
- const jaccard = union > 0 ? intersection / union : 0;
1451
+ const { jaccard: jaccardScore } = require_utils.computeJaccard(graph, source, target);
1010
1452
  const edge = graph.getEdge(source, target);
1011
- if (edge?.type === void 0) return Math.max(epsilon, jaccard);
1453
+ if (edge?.type === void 0) return Math.max(epsilon, jaccardScore);
1012
1454
  const edgeTypeCount = require_utils.countEdgesOfType(graph, edge.type);
1013
- if (edgeTypeCount === 0) return Math.max(epsilon, jaccard);
1014
- 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);
1015
1457
  return Math.max(epsilon, score);
1016
1458
  }
1017
1459
  //#endregion
@@ -1021,17 +1463,16 @@ function etch(graph, source, target, config) {
1021
1463
  */
1022
1464
  function notch(graph, source, target, config) {
1023
1465
  const { epsilon = 1e-10 } = config ?? {};
1024
- const { intersection, union } = require_utils.neighbourOverlap(require_utils.neighbourSet(graph, source, target), require_utils.neighbourSet(graph, target, source));
1025
- const jaccard = union > 0 ? intersection / union : 0;
1466
+ const { jaccard: jaccardScore } = require_utils.computeJaccard(graph, source, target);
1026
1467
  const sourceNode = graph.getNode(source);
1027
1468
  const targetNode = graph.getNode(target);
1028
- 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);
1029
1470
  const sourceTypeCount = require_utils.countNodesOfType(graph, sourceNode.type);
1030
1471
  const targetTypeCount = require_utils.countNodesOfType(graph, targetNode.type);
1031
- if (sourceTypeCount === 0 || targetTypeCount === 0) return Math.max(epsilon, jaccard);
1472
+ if (sourceTypeCount === 0 || targetTypeCount === 0) return Math.max(epsilon, jaccardScore);
1032
1473
  const sourceRarity = Math.log(graph.nodeCount / sourceTypeCount);
1033
1474
  const targetRarity = Math.log(graph.nodeCount / targetTypeCount);
1034
- const score = jaccard * sourceRarity * targetRarity;
1475
+ const score = jaccardScore * sourceRarity * targetRarity;
1035
1476
  return Math.max(epsilon, score);
1036
1477
  }
1037
1478
  //#endregion
@@ -1050,13 +1491,12 @@ function notch(graph, source, target, config) {
1050
1491
  */
1051
1492
  function adaptive(graph, source, target, config) {
1052
1493
  const { epsilon = 1e-10, structuralWeight = .4, degreeWeight = .3, overlapWeight = .3 } = config ?? {};
1053
- 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);
1054
1496
  const degreeComponent = adamicAdar(graph, source, target, {
1055
1497
  epsilon,
1056
1498
  normalise: true
1057
1499
  });
1058
- const sourceNeighbours = require_utils.neighbourSet(graph, source, target);
1059
- const targetNeighbours = require_utils.neighbourSet(graph, target, source);
1060
1500
  let overlap;
1061
1501
  if (sourceNeighbours.size > 0 && targetNeighbours.size > 0) {
1062
1502
  const { intersection } = require_utils.neighbourOverlap(sourceNeighbours, targetNeighbours);
@@ -1068,6 +1508,42 @@ function adaptive(graph, source, target, config) {
1068
1508
  return Math.max(epsilon, Math.min(1, score));
1069
1509
  }
1070
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
1071
1547
  //#region src/ranking/baselines/shortest.ts
1072
1548
  /**
1073
1549
  * Rank paths by length (shortest first).
@@ -1085,18 +1561,10 @@ function shortest(_graph, paths, config) {
1085
1561
  paths: [],
1086
1562
  method: "shortest"
1087
1563
  };
1088
- const scored = paths.map((path) => ({
1564
+ return normaliseAndRank(paths, paths.map((path) => ({
1089
1565
  path,
1090
1566
  score: 1 / path.nodes.length
1091
- }));
1092
- const maxScore = Math.max(...scored.map((s) => s.score));
1093
- return {
1094
- paths: scored.map(({ path, score }) => ({
1095
- ...path,
1096
- score: includeScores ? score / maxScore : score / maxScore
1097
- })).sort((a, b) => b.score - a.score),
1098
- method: "shortest"
1099
- };
1567
+ })), "shortest", includeScores);
1100
1568
  }
1101
1569
  //#endregion
1102
1570
  //#region src/ranking/baselines/degree-sum.ts
@@ -1114,29 +1582,14 @@ function degreeSum(graph, paths, config) {
1114
1582
  paths: [],
1115
1583
  method: "degree-sum"
1116
1584
  };
1117
- const scored = paths.map((path) => {
1585
+ return normaliseAndRank(paths, paths.map((path) => {
1118
1586
  let degreeSum = 0;
1119
1587
  for (const nodeId of path.nodes) degreeSum += graph.degree(nodeId);
1120
1588
  return {
1121
1589
  path,
1122
1590
  score: degreeSum
1123
1591
  };
1124
- });
1125
- const maxScore = Math.max(...scored.map((s) => s.score));
1126
- if (maxScore === 0) return {
1127
- paths: paths.map((path) => ({
1128
- ...path,
1129
- score: 0
1130
- })),
1131
- method: "degree-sum"
1132
- };
1133
- return {
1134
- paths: scored.map(({ path, score }) => ({
1135
- ...path,
1136
- score: includeScores ? score / maxScore : score
1137
- })).sort((a, b) => b.score - a.score),
1138
- method: "degree-sum"
1139
- };
1592
+ }), "degree-sum", includeScores);
1140
1593
  }
1141
1594
  //#endregion
1142
1595
  //#region src/ranking/baselines/widest-path.ts
@@ -1154,7 +1607,7 @@ function widestPath(graph, paths, config) {
1154
1607
  paths: [],
1155
1608
  method: "widest-path"
1156
1609
  };
1157
- const scored = paths.map((path) => {
1610
+ return normaliseAndRank(paths, paths.map((path) => {
1158
1611
  if (path.nodes.length < 2) return {
1159
1612
  path,
1160
1613
  score: 1
@@ -1171,22 +1624,7 @@ function widestPath(graph, paths, config) {
1171
1624
  path,
1172
1625
  score: minSimilarity === Number.POSITIVE_INFINITY ? 1 : minSimilarity
1173
1626
  };
1174
- });
1175
- const maxScore = Math.max(...scored.map((s) => s.score));
1176
- if (maxScore === 0) return {
1177
- paths: paths.map((path) => ({
1178
- ...path,
1179
- score: 0
1180
- })),
1181
- method: "widest-path"
1182
- };
1183
- return {
1184
- paths: scored.map(({ path, score }) => ({
1185
- ...path,
1186
- score: includeScores ? score / maxScore : score / maxScore
1187
- })).sort((a, b) => b.score - a.score),
1188
- method: "widest-path"
1189
- };
1627
+ }), "widest-path", includeScores);
1190
1628
  }
1191
1629
  //#endregion
1192
1630
  //#region src/ranking/baselines/jaccard-arithmetic.ts
@@ -1204,7 +1642,7 @@ function jaccardArithmetic(graph, paths, config) {
1204
1642
  paths: [],
1205
1643
  method: "jaccard-arithmetic"
1206
1644
  };
1207
- const scored = paths.map((path) => {
1645
+ return normaliseAndRank(paths, paths.map((path) => {
1208
1646
  if (path.nodes.length < 2) return {
1209
1647
  path,
1210
1648
  score: 1
@@ -1223,22 +1661,7 @@ function jaccardArithmetic(graph, paths, config) {
1223
1661
  path,
1224
1662
  score: edgeCount > 0 ? similaritySum / edgeCount : 1
1225
1663
  };
1226
- });
1227
- const maxScore = Math.max(...scored.map((s) => s.score));
1228
- if (maxScore === 0) return {
1229
- paths: paths.map((path) => ({
1230
- ...path,
1231
- score: 0
1232
- })),
1233
- method: "jaccard-arithmetic"
1234
- };
1235
- return {
1236
- paths: scored.map(({ path, score }) => ({
1237
- ...path,
1238
- score: includeScores ? score / maxScore : score
1239
- })).sort((a, b) => b.score - a.score),
1240
- method: "jaccard-arithmetic"
1241
- };
1664
+ }), "jaccard-arithmetic", includeScores);
1242
1665
  }
1243
1666
  //#endregion
1244
1667
  //#region src/ranking/baselines/pagerank.ts
@@ -1299,29 +1722,14 @@ function pagerank(graph, paths, config) {
1299
1722
  method: "pagerank"
1300
1723
  };
1301
1724
  const ranks = computePageRank(graph);
1302
- const scored = paths.map((path) => {
1725
+ return normaliseAndRank(paths, paths.map((path) => {
1303
1726
  let prSum = 0;
1304
1727
  for (const nodeId of path.nodes) prSum += ranks.get(nodeId) ?? 0;
1305
1728
  return {
1306
1729
  path,
1307
1730
  score: prSum
1308
1731
  };
1309
- });
1310
- const maxScore = Math.max(...scored.map((s) => s.score));
1311
- if (maxScore === 0) return {
1312
- paths: paths.map((path) => ({
1313
- ...path,
1314
- score: 0
1315
- })),
1316
- method: "pagerank"
1317
- };
1318
- return {
1319
- paths: scored.map(({ path, score }) => ({
1320
- ...path,
1321
- score: includeScores ? score / maxScore : score / maxScore
1322
- })).sort((a, b) => b.score - a.score),
1323
- method: "pagerank"
1324
- };
1732
+ }), "pagerank", includeScores);
1325
1733
  }
1326
1734
  //#endregion
1327
1735
  //#region src/ranking/baselines/betweenness.ts
@@ -1406,29 +1814,14 @@ function betweenness(graph, paths, config) {
1406
1814
  method: "betweenness"
1407
1815
  };
1408
1816
  const bcMap = computeBetweenness(graph);
1409
- const scored = paths.map((path) => {
1817
+ return normaliseAndRank(paths, paths.map((path) => {
1410
1818
  let bcSum = 0;
1411
1819
  for (const nodeId of path.nodes) bcSum += bcMap.get(nodeId) ?? 0;
1412
1820
  return {
1413
1821
  path,
1414
1822
  score: bcSum
1415
1823
  };
1416
- });
1417
- const maxScore = Math.max(...scored.map((s) => s.score));
1418
- if (maxScore === 0) return {
1419
- paths: paths.map((path) => ({
1420
- ...path,
1421
- score: 0
1422
- })),
1423
- method: "betweenness"
1424
- };
1425
- return {
1426
- paths: scored.map(({ path, score }) => ({
1427
- ...path,
1428
- score: includeScores ? score / maxScore : score / maxScore
1429
- })).sort((a, b) => b.score - a.score),
1430
- method: "betweenness"
1431
- };
1824
+ }), "betweenness", includeScores);
1432
1825
  }
1433
1826
  //#endregion
1434
1827
  //#region src/ranking/baselines/katz.ts
@@ -1491,7 +1884,7 @@ function katz(graph, paths, config) {
1491
1884
  paths: [],
1492
1885
  method: "katz"
1493
1886
  };
1494
- const scored = paths.map((path) => {
1887
+ return normaliseAndRank(paths, paths.map((path) => {
1495
1888
  const source = path.nodes[0];
1496
1889
  const target = path.nodes[path.nodes.length - 1];
1497
1890
  if (source === void 0 || target === void 0) return {
@@ -1502,22 +1895,7 @@ function katz(graph, paths, config) {
1502
1895
  path,
1503
1896
  score: computeKatz(graph, source, target)
1504
1897
  };
1505
- });
1506
- const maxScore = Math.max(...scored.map((s) => s.score));
1507
- if (maxScore === 0) return {
1508
- paths: paths.map((path) => ({
1509
- ...path,
1510
- score: 0
1511
- })),
1512
- method: "katz"
1513
- };
1514
- return {
1515
- paths: scored.map(({ path, score }) => ({
1516
- ...path,
1517
- score: includeScores ? score / maxScore : score / maxScore
1518
- })).sort((a, b) => b.score - a.score),
1519
- method: "katz"
1520
- };
1898
+ }), "katz", includeScores);
1521
1899
  }
1522
1900
  //#endregion
1523
1901
  //#region src/ranking/baselines/communicability.ts
@@ -1579,7 +1957,7 @@ function communicability(graph, paths, config) {
1579
1957
  paths: [],
1580
1958
  method: "communicability"
1581
1959
  };
1582
- const scored = paths.map((path) => {
1960
+ return normaliseAndRank(paths, paths.map((path) => {
1583
1961
  const source = path.nodes[0];
1584
1962
  const target = path.nodes[path.nodes.length - 1];
1585
1963
  if (source === void 0 || target === void 0) return {
@@ -1590,22 +1968,7 @@ function communicability(graph, paths, config) {
1590
1968
  path,
1591
1969
  score: computeCommunicability(graph, source, target)
1592
1970
  };
1593
- });
1594
- const maxScore = Math.max(...scored.map((s) => s.score));
1595
- if (maxScore === 0) return {
1596
- paths: paths.map((path) => ({
1597
- ...path,
1598
- score: 0
1599
- })),
1600
- method: "communicability"
1601
- };
1602
- return {
1603
- paths: scored.map(({ path, score }) => ({
1604
- ...path,
1605
- score: includeScores ? score / maxScore : score / maxScore
1606
- })).sort((a, b) => b.score - a.score),
1607
- method: "communicability"
1608
- };
1971
+ }), "communicability", includeScores);
1609
1972
  }
1610
1973
  //#endregion
1611
1974
  //#region src/ranking/baselines/resistance-distance.ts
@@ -1722,7 +2085,7 @@ function resistanceDistance(graph, paths, config) {
1722
2085
  };
1723
2086
  const nodeCount = Array.from(graph.nodeIds()).length;
1724
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.`);
1725
- const scored = paths.map((path) => {
2088
+ return normaliseAndRank(paths, paths.map((path) => {
1726
2089
  const source = path.nodes[0];
1727
2090
  const target = path.nodes[path.nodes.length - 1];
1728
2091
  if (source === void 0 || target === void 0) return {
@@ -1733,22 +2096,7 @@ function resistanceDistance(graph, paths, config) {
1733
2096
  path,
1734
2097
  score: 1 / computeResistance(graph, source, target)
1735
2098
  };
1736
- });
1737
- const maxScore = Math.max(...scored.map((s) => s.score));
1738
- if (maxScore === 0) return {
1739
- paths: paths.map((path) => ({
1740
- ...path,
1741
- score: 0
1742
- })),
1743
- method: "resistance-distance"
1744
- };
1745
- return {
1746
- paths: scored.map(({ path, score }) => ({
1747
- ...path,
1748
- score: includeScores ? score / maxScore : score / maxScore
1749
- })).sort((a, b) => b.score - a.score),
1750
- method: "resistance-distance"
1751
- };
2099
+ }), "resistance-distance", includeScores);
1752
2100
  }
1753
2101
  //#endregion
1754
2102
  //#region src/ranking/baselines/random-ranking.ts
@@ -1782,27 +2130,258 @@ function randomRanking(_graph, paths, config) {
1782
2130
  paths: [],
1783
2131
  method: "random"
1784
2132
  };
1785
- const scored = paths.map((path) => {
2133
+ return normaliseAndRank(paths, paths.map((path) => {
1786
2134
  return {
1787
2135
  path,
1788
2136
  score: seededRandom(path.nodes.join(","), seed)
1789
2137
  };
2138
+ }), "random", includeScores);
2139
+ }
2140
+ //#endregion
2141
+ //#region src/ranking/baselines/hitting-time.ts
2142
+ /**
2143
+ * Seeded deterministic random number generator (LCG).
2144
+ * Suitable for reproducible random walk simulation.
2145
+ */
2146
+ var SeededRNG = class {
2147
+ state;
2148
+ constructor(seed) {
2149
+ this.state = seed;
2150
+ }
2151
+ /**
2152
+ * Generate next pseudorandom value in [0, 1).
2153
+ */
2154
+ next() {
2155
+ this.state = this.state * 1103515245 + 12345 & 2147483647;
2156
+ return this.state / 2147483647;
2157
+ }
2158
+ };
2159
+ /**
2160
+ * Compute hitting time via Monte Carlo random walk simulation.
2161
+ *
2162
+ * @param graph - Source graph
2163
+ * @param source - Source node ID
2164
+ * @param target - Target node ID
2165
+ * @param walks - Number of walks to simulate
2166
+ * @param maxSteps - Maximum steps per walk
2167
+ * @param rng - Seeded RNG instance
2168
+ * @returns Average hitting time across walks
2169
+ */
2170
+ function computeHittingTimeApproximate(graph, source, target, walks, maxSteps, rng) {
2171
+ if (source === target) return 0;
2172
+ let totalSteps = 0;
2173
+ let successfulWalks = 0;
2174
+ for (let w = 0; w < walks; w++) {
2175
+ let current = source;
2176
+ let steps = 0;
2177
+ while (current !== target && steps < maxSteps) {
2178
+ const neighbours = Array.from(graph.neighbours(current));
2179
+ if (neighbours.length === 0) break;
2180
+ const nextNode = neighbours[Math.floor(rng.next() * neighbours.length)];
2181
+ if (nextNode === void 0) break;
2182
+ current = nextNode;
2183
+ steps++;
2184
+ }
2185
+ if (current === target) {
2186
+ totalSteps += steps;
2187
+ successfulWalks++;
2188
+ }
2189
+ }
2190
+ if (successfulWalks > 0) return totalSteps / successfulWalks;
2191
+ return maxSteps;
2192
+ }
2193
+ /**
2194
+ * Compute hitting time via exact fundamental matrix method.
2195
+ *
2196
+ * For small graphs, computes exact expected hitting times using
2197
+ * the fundamental matrix of the random walk.
2198
+ *
2199
+ * @param graph - Source graph
2200
+ * @param source - Source node ID
2201
+ * @param target - Target node ID
2202
+ * @returns Exact hitting time (or approximation if convergence fails)
2203
+ */
2204
+ function computeHittingTimeExact(graph, source, target) {
2205
+ if (source === target) return 0;
2206
+ const nodes = Array.from(graph.nodeIds());
2207
+ const nodeToIdx = /* @__PURE__ */ new Map();
2208
+ nodes.forEach((nodeId, idx) => {
2209
+ nodeToIdx.set(nodeId, idx);
2210
+ });
2211
+ const n = nodes.length;
2212
+ const sourceIdx = nodeToIdx.get(source);
2213
+ const targetIdx = nodeToIdx.get(target);
2214
+ if (sourceIdx === void 0 || targetIdx === void 0) return 0;
2215
+ const P = [];
2216
+ for (let i = 0; i < n; i++) {
2217
+ const row = [];
2218
+ for (let j = 0; j < n; j++) row[j] = 0;
2219
+ P[i] = row;
2220
+ }
2221
+ for (const nodeId of nodes) {
2222
+ const idx = nodeToIdx.get(nodeId);
2223
+ if (idx === void 0) continue;
2224
+ const pRow = P[idx];
2225
+ if (pRow === void 0) continue;
2226
+ if (idx === targetIdx) pRow[idx] = 1;
2227
+ else {
2228
+ const neighbours = Array.from(graph.neighbours(nodeId));
2229
+ const degree = neighbours.length;
2230
+ if (degree > 0) for (const neighbourId of neighbours) {
2231
+ const nIdx = nodeToIdx.get(neighbourId);
2232
+ if (nIdx !== void 0) pRow[nIdx] = 1 / degree;
2233
+ }
2234
+ }
2235
+ }
2236
+ const transientIndices = [];
2237
+ for (let i = 0; i < n; i++) if (i !== targetIdx) transientIndices.push(i);
2238
+ const m = transientIndices.length;
2239
+ const Q = [];
2240
+ for (let i = 0; i < m; i++) {
2241
+ const row = [];
2242
+ for (let j = 0; j < m; j++) row[j] = 0;
2243
+ Q[i] = row;
2244
+ }
2245
+ for (let i = 0; i < m; i++) {
2246
+ const qRow = Q[i];
2247
+ if (qRow === void 0) continue;
2248
+ const origI = transientIndices[i];
2249
+ if (origI === void 0) continue;
2250
+ const pRow = P[origI];
2251
+ if (pRow === void 0) continue;
2252
+ for (let j = 0; j < m; j++) {
2253
+ const origJ = transientIndices[j];
2254
+ if (origJ === void 0) continue;
2255
+ qRow[j] = pRow[origJ] ?? 0;
2256
+ }
2257
+ }
2258
+ const IMQ = [];
2259
+ for (let i = 0; i < m; i++) {
2260
+ const row = [];
2261
+ for (let j = 0; j < m; j++) row[j] = i === j ? 1 : 0;
2262
+ IMQ[i] = row;
2263
+ }
2264
+ for (let i = 0; i < m; i++) {
2265
+ const imqRow = IMQ[i];
2266
+ if (imqRow === void 0) continue;
2267
+ const qRow = Q[i];
2268
+ for (let j = 0; j < m; j++) {
2269
+ const qVal = qRow?.[j] ?? 0;
2270
+ imqRow[j] = (i === j ? 1 : 0) - qVal;
2271
+ }
2272
+ }
2273
+ const N = invertMatrix(IMQ);
2274
+ if (N === null) return 1;
2275
+ const sourceTransientIdx = transientIndices.indexOf(sourceIdx);
2276
+ if (sourceTransientIdx < 0) return 0;
2277
+ let hittingTime = 0;
2278
+ const row = N[sourceTransientIdx];
2279
+ if (row !== void 0) for (const val of row) hittingTime += val;
2280
+ return hittingTime;
2281
+ }
2282
+ /**
2283
+ * Invert a square matrix using Gaussian elimination with partial pivoting.
2284
+ *
2285
+ * @param matrix - Input matrix (n × n)
2286
+ * @returns Inverted matrix, or null if singular
2287
+ */
2288
+ function invertMatrix(matrix) {
2289
+ const n = matrix.length;
2290
+ const aug = [];
2291
+ for (let i = 0; i < n; i++) {
2292
+ const row = [];
2293
+ const matRow = matrix[i];
2294
+ for (let j = 0; j < n; j++) row[j] = matRow?.[j] ?? 0;
2295
+ for (let j = 0; j < n; j++) row[n + j] = i === j ? 1 : 0;
2296
+ aug[i] = row;
2297
+ }
2298
+ for (let col = 0; col < n; col++) {
2299
+ let pivotRow = col;
2300
+ const pivotCol = aug[pivotRow];
2301
+ if (pivotCol === void 0) return null;
2302
+ for (let row = col + 1; row < n; row++) {
2303
+ const currRowVal = aug[row]?.[col] ?? 0;
2304
+ const pivotRowVal = pivotCol[col] ?? 0;
2305
+ if (Math.abs(currRowVal) > Math.abs(pivotRowVal)) pivotRow = row;
2306
+ }
2307
+ const augPivot = aug[pivotRow];
2308
+ if (augPivot === void 0 || Math.abs(augPivot[col] ?? 0) < 1e-10) return null;
2309
+ [aug[col], aug[pivotRow]] = [aug[pivotRow] ?? [], aug[col] ?? []];
2310
+ const scaledPivotRow = aug[col];
2311
+ if (scaledPivotRow === void 0) return null;
2312
+ const pivot = scaledPivotRow[col] ?? 1;
2313
+ for (let j = 0; j < 2 * n; j++) scaledPivotRow[j] = (scaledPivotRow[j] ?? 0) / pivot;
2314
+ for (let row = col + 1; row < n; row++) {
2315
+ const currRow = aug[row];
2316
+ if (currRow === void 0) continue;
2317
+ const factor = currRow[col] ?? 0;
2318
+ for (let j = 0; j < 2 * n; j++) currRow[j] = (currRow[j] ?? 0) - factor * (scaledPivotRow[j] ?? 0);
2319
+ }
2320
+ }
2321
+ for (let col = n - 1; col > 0; col--) {
2322
+ const colRow = aug[col];
2323
+ if (colRow === void 0) return null;
2324
+ for (let row = col - 1; row >= 0; row--) {
2325
+ const currRow = aug[row];
2326
+ if (currRow === void 0) continue;
2327
+ const factor = currRow[col] ?? 0;
2328
+ for (let j = 0; j < 2 * n; j++) currRow[j] = (currRow[j] ?? 0) - factor * (colRow[j] ?? 0);
2329
+ }
2330
+ }
2331
+ const inv = [];
2332
+ for (let i = 0; i < n; i++) {
2333
+ const row = [];
2334
+ for (let j = 0; j < n; j++) row[j] = 0;
2335
+ inv[i] = row;
2336
+ }
2337
+ for (let i = 0; i < n; i++) {
2338
+ const invRow = inv[i];
2339
+ if (invRow === void 0) continue;
2340
+ const augRow = aug[i];
2341
+ if (augRow === void 0) continue;
2342
+ for (let j = 0; j < n; j++) invRow[j] = augRow[n + j] ?? 0;
2343
+ }
2344
+ return inv;
2345
+ }
2346
+ /**
2347
+ * Rank paths by inverse hitting time between endpoints.
2348
+ *
2349
+ * @param graph - Source graph
2350
+ * @param paths - Paths to rank
2351
+ * @param config - Configuration options
2352
+ * @returns Ranked paths (highest inverse hitting time first)
2353
+ */
2354
+ function hittingTime(graph, paths, config) {
2355
+ const { includeScores = true, mode = "auto", walks = 1e3, maxSteps = 1e4, seed = 42 } = config ?? {};
2356
+ if (paths.length === 0) return {
2357
+ paths: [],
2358
+ method: "hitting-time"
2359
+ };
2360
+ const nodeCount = Array.from(graph.nodeIds()).length;
2361
+ const actualMode = mode === "auto" ? nodeCount < 100 ? "exact" : "approximate" : mode;
2362
+ const rng = new SeededRNG(seed);
2363
+ const scored = paths.map((path) => {
2364
+ const source = path.nodes[0];
2365
+ const target = path.nodes[path.nodes.length - 1];
2366
+ if (source === void 0 || target === void 0) return {
2367
+ path,
2368
+ score: 0
2369
+ };
2370
+ const ht = actualMode === "exact" ? computeHittingTimeExact(graph, source, target) : computeHittingTimeApproximate(graph, source, target, walks, maxSteps, rng);
2371
+ return {
2372
+ path,
2373
+ score: ht > 0 ? 1 / ht : 0
2374
+ };
1790
2375
  });
1791
2376
  const maxScore = Math.max(...scored.map((s) => s.score));
1792
- if (maxScore === 0) return {
2377
+ if (!Number.isFinite(maxScore)) return {
1793
2378
  paths: paths.map((path) => ({
1794
2379
  ...path,
1795
2380
  score: 0
1796
2381
  })),
1797
- method: "random"
1798
- };
1799
- return {
1800
- paths: scored.map(({ path, score }) => ({
1801
- ...path,
1802
- score: includeScores ? score / maxScore : score / maxScore
1803
- })).sort((a, b) => b.score - a.score),
1804
- method: "random"
2382
+ method: "hitting-time"
1805
2383
  };
2384
+ return normaliseAndRank(paths, scored, "hitting-time", includeScores);
1806
2385
  }
1807
2386
  //#endregion
1808
2387
  //#region src/extraction/ego-network.ts
@@ -2437,7 +3016,9 @@ exports.betweenness = betweenness;
2437
3016
  exports.bfs = require_traversal.bfs;
2438
3017
  exports.bfsWithPath = require_traversal.bfsWithPath;
2439
3018
  exports.communicability = communicability;
3019
+ exports.computeJaccard = require_utils.computeJaccard;
2440
3020
  exports.computeTrussNumbers = computeTrussNumbers;
3021
+ exports.cosine = cosine;
2441
3022
  exports.countEdgesOfType = require_utils.countEdgesOfType;
2442
3023
  exports.countNodesOfType = require_utils.countNodesOfType;
2443
3024
  exports.createGPUContext = require_gpu.createGPUContext;
@@ -2446,6 +3027,8 @@ exports.csrToGPUBuffers = require_gpu.csrToGPUBuffers;
2446
3027
  exports.degreeSum = degreeSum;
2447
3028
  exports.detectWebGPU = require_gpu.detectWebGPU;
2448
3029
  exports.dfs = require_traversal.dfs;
3030
+ exports.dfsPriority = dfsPriority;
3031
+ exports.dfsPriorityFn = dfsPriorityFn;
2449
3032
  exports.dfsWithPath = require_traversal.dfsWithPath;
2450
3033
  exports.dome = dome;
2451
3034
  exports.domeHighDegree = domeHighDegree;
@@ -2467,9 +3050,12 @@ exports.getMotifName = getMotifName;
2467
3050
  exports.graphToCSR = require_gpu.graphToCSR;
2468
3051
  exports.grasp = require_seeds.grasp;
2469
3052
  exports.hae = hae;
3053
+ exports.hittingTime = hittingTime;
3054
+ exports.hubPromoted = hubPromoted;
2470
3055
  exports.isWebGPUAvailable = require_gpu.isWebGPUAvailable;
2471
3056
  exports.jaccard = jaccard;
2472
3057
  exports.jaccardArithmetic = jaccardArithmetic;
3058
+ exports.kHop = kHop;
2473
3059
  exports.katz = katz;
2474
3060
  exports.lace = lace;
2475
3061
  exports.localClusteringCoefficient = require_utils.localClusteringCoefficient;
@@ -2483,20 +3069,24 @@ exports.normaliseFeatures = require_kmeans.normaliseFeatures;
2483
3069
  exports.zScoreNormalise = require_kmeans.normaliseFeatures;
2484
3070
  exports.normalisedEntropy = require_utils.normalisedEntropy;
2485
3071
  exports.notch = notch;
3072
+ exports.overlapCoefficient = overlapCoefficient;
2486
3073
  exports.pagerank = pagerank;
2487
3074
  exports.parse = parse;
2488
3075
  exports.pipe = pipe;
2489
3076
  exports.randomPriority = randomPriority;
2490
3077
  exports.randomRanking = randomRanking;
3078
+ exports.randomWalk = randomWalk;
2491
3079
  exports.reach = reach;
2492
3080
  exports.readBufferToCPU = require_gpu.readBufferToCPU;
2493
3081
  exports.resistanceDistance = resistanceDistance;
3082
+ exports.resourceAllocation = resourceAllocation;
2494
3083
  exports.sage = sage;
2495
3084
  exports.scale = scale;
2496
3085
  exports.shannonEntropy = require_utils.shannonEntropy;
2497
3086
  exports.shortest = shortest;
2498
3087
  exports.sift = sift;
2499
3088
  exports.skew = skew;
3089
+ exports.sorensen = sorensen;
2500
3090
  exports.span = span;
2501
3091
  exports.standardBfs = standardBfs;
2502
3092
  exports.stratified = require_seeds.stratified;