bmssp 1.0.1 → 1.1.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.
@@ -0,0 +1,136 @@
1
+ // Constant-degree transform — the paper's Preliminaries (knowledge/01 §
2
+ // "Preliminaries / model assumptions"; term in knowledge/07). The
3
+ // O(m·log^(2/3) n) bound is argued on a graph whose vertices all have
4
+ // in-degree AND out-degree <= 2. This module rewrites any graph into that
5
+ // shape while preserving every distance.
6
+ //
7
+ // OPT-IN, by design. Nothing in the core algorithm calls this: BMSSP is
8
+ // correct on the untransformed graph (validated node-by-node against the
9
+ // Dijkstra oracle). The transform only realizes the paper's structural
10
+ // precondition for callers who want it — it changes constants, never
11
+ // correctness (issue #164, milestone 1.1.0).
12
+
13
+ // How it works: split each vertex into one "port" copy per incident edge
14
+ // endpoint and thread those copies onto a zero-weight directed cycle. A cycle
15
+ // gives every copy exactly one incoming and one outgoing cycle edge; a copy
16
+ // that additionally hosts a single original endpoint therefore reaches
17
+ // degree 2 on that one side and stays at 1 on the other — never more. Because
18
+ // the cycle costs nothing to traverse, all copies of a vertex are mutually
19
+ // reachable at zero added distance, so each copy's shortest distance equals
20
+ // the original vertex's: the rewrite is distance-preserving.
21
+
22
+ /**
23
+ * Rewrite a directed graph so every vertex has in-degree and out-degree <= 2,
24
+ * preserving all shortest-path distances.
25
+ *
26
+ * @param {Array<[number, number, number]>} graph - [from, to, weight] edges,
27
+ * the same input contract as the BMSSP constructor: an array of exact
28
+ * three-element arrays with finite numeric node IDs and finite, non-negative
29
+ * weights. An empty array is valid.
30
+ * @returns {{
31
+ * edges: Array<[number, number, number]>,
32
+ * copiesOf: Map<number, number[]>,
33
+ * originalOf: Map<number, number>,
34
+ * sourceCopy: (original: number) => number,
35
+ * collapse: (transformedDistances: Map<number, number>) => Map<number, number>
36
+ * }}
37
+ * - `edges`: the transformed graph (fresh integer copy IDs from 0).
38
+ * - `copiesOf`: original node ID -> its copy IDs, in allocation order.
39
+ * - `originalOf`: copy ID -> the original node ID it belongs to.
40
+ * - `sourceCopy(original)`: a canonical copy to start a run from (any copy
41
+ * works — the zero-weight cycle equalizes them); throws for an unknown
42
+ * node.
43
+ * - `collapse(distances)`: fold a transformed-graph distance map back onto
44
+ * the original node IDs (the min over each vertex's copies; all copies
45
+ * share the same value, so the min is exact).
46
+ */
47
+ function constantDegreeTransform(graph) {
48
+ if (!Array.isArray(graph)) {
49
+ throw new Error("Input graph must be an array of edges");
50
+ }
51
+
52
+ let nextCopyId = 0;
53
+ // original node ID -> its copy IDs, in allocation order
54
+ const copiesOf = new Map();
55
+ // copy ID -> the original node ID it belongs to
56
+ const originalOf = new Map();
57
+ // per original edge, the out-port copy of `from` and the in-port copy of `to`
58
+ const outCopyForEdge = new Array(graph.length);
59
+ const inCopyForEdge = new Array(graph.length);
60
+
61
+ const allocateCopy = (originalId) => {
62
+ const id = nextCopyId;
63
+ nextCopyId += 1;
64
+ originalOf.set(id, originalId);
65
+ let copies = copiesOf.get(originalId);
66
+ if (copies === undefined) {
67
+ copies = [];
68
+ copiesOf.set(originalId, copies);
69
+ }
70
+ copies.push(id);
71
+ return id;
72
+ };
73
+
74
+ // One copy per edge endpoint, allocated in edge order so the whole transform
75
+ // is deterministic: the out-port at `from`, the in-port at `to`. Validate
76
+ // exactly like the BMSSP constructor so the transform can front any graph.
77
+ graph.forEach((edge, index) => {
78
+ if (!Array.isArray(edge) || edge.length !== 3) {
79
+ throw new Error(`Edge at index ${index} must be [from, to, weight]`);
80
+ }
81
+ const [from, to, weight] = edge;
82
+ if (!Number.isFinite(from) || !Number.isFinite(to)) {
83
+ throw new Error(`Edge at index ${index} must have numeric node IDs`);
84
+ }
85
+ if (!Number.isFinite(weight) || weight < 0) {
86
+ throw new Error(
87
+ `Edge at index ${index} must have a non-negative numeric weight`,
88
+ );
89
+ }
90
+ outCopyForEdge[index] = allocateCopy(from);
91
+ inCopyForEdge[index] = allocateCopy(to);
92
+ });
93
+
94
+ const edges = [];
95
+
96
+ // Zero-weight directed cycle through each vertex's copies. A vertex with a
97
+ // single incident endpoint has one copy and needs no cycle.
98
+ for (const copies of copiesOf.values()) {
99
+ if (copies.length < 2) continue;
100
+ for (let i = 0; i < copies.length; i += 1) {
101
+ const next = copies[(i + 1) % copies.length];
102
+ edges.push([copies[i], next, 0]);
103
+ }
104
+ }
105
+
106
+ // Each original edge connects its own out-copy to its own in-copy, weight
107
+ // unchanged.
108
+ graph.forEach(([, , weight], index) => {
109
+ edges.push([outCopyForEdge[index], inCopyForEdge[index], weight]);
110
+ });
111
+
112
+ const sourceCopy = (original) => {
113
+ const copies = copiesOf.get(original);
114
+ if (copies === undefined) {
115
+ throw new Error(`Node ${original} is not in the graph`);
116
+ }
117
+ return copies[0];
118
+ };
119
+
120
+ const collapse = (transformedDistances) => {
121
+ const result = new Map();
122
+ for (const [original, copies] of copiesOf) {
123
+ let best = Infinity;
124
+ for (const copy of copies) {
125
+ const d = transformedDistances.get(copy);
126
+ if (d !== undefined && d < best) best = d;
127
+ }
128
+ result.set(original, best);
129
+ }
130
+ return result;
131
+ };
132
+
133
+ return { edges, copiesOf, originalOf, sourceCopy, collapse };
134
+ }
135
+
136
+ export { constantDegreeTransform };
@@ -1,3 +1,5 @@
1
+ import { compareKeys, toBound, makeTies, relaxEdge } from "./tieBreak.mjs";
2
+
1
3
  /**
2
4
  * FindPivots(B, S) — Algorithm 1 of "Breaking the Sorting Barrier for Directed
3
5
  * Single-Source Shortest Paths". Shrinks the frontier S: runs k rounds of
@@ -13,30 +15,42 @@
13
15
  *
14
16
  * Distance estimates are read from and written into dHat in place — exactly
15
17
  * like the paper's global d̂[·] labels, so improvements made here are visible
16
- * to the levels above. The `<=` relaxation updates d̂ unconditionally; the
17
- * `< B` test only gates membership in W.
18
+ * to the levels above. Relaxation is canonical (#163, src/tieBreak.mjs):
19
+ * d̂, hops and preds move together under the composite [length, hops, id]
20
+ * order, and an exact-equality result re-admits a vertex to W through its
21
+ * one canonical label-setter (the paper's `≤` made deterministic). The `< B`
22
+ * test only gates membership in W; d̂ updates are not gated.
23
+ *
24
+ * The paper's tight-edge forest falls out of the canonical labels: each
25
+ * vertex of W \ S hangs off its recorded canonical predecessor, which is
26
+ * always itself in W. Equal-length paths cannot make this a DAG (one pred
27
+ * per vertex) and tight zero-weight cycles cannot occur (a zero-weight edge
28
+ * strictly increases the hops component), so parent chains always terminate
29
+ * in S — the two #44-era tie ambiguities are gone by construction. Members
30
+ * of S are roots by definition and never count toward another source's tree.
18
31
  *
19
32
  * Two outcomes:
20
33
  * - Early exit (|W| grows past k·|S|): the frontier is already small relative
21
34
  * to W, so every vertex of S is a pivot.
22
35
  * - Forest case (k rounds complete with |W| <= k·|S|): pivots are the S-roots
23
- * of tight-edge trees with >= k vertices. Equal-length paths can make the
24
- * tight-edge graph a DAG (see #163); each vertex is assigned at most one
25
- * parent deterministically (first tight edge in W iteration order) so tree
26
- * sizes are well-defined. Guarantees |pivots| <= |W| / k either way.
36
+ * of canonical-predecessor trees with >= k vertices.
37
+ * Guarantees |pivots| <= |W| / k either way.
27
38
  *
28
- * @param {number} B - Strict upper bound gating membership in W (Infinity is allowed)
39
+ * @param {number|[number, number, *]} B - Strict upper bound gating membership
40
+ * in W: a number (Infinity is allowed) or a composite bound
29
41
  * @param {Set<*>|Iterable<*>} S - Non-empty set of complete frontier sources
30
42
  * @param {Map<*, number>} dHat - Global distance estimates d̂[·], updated in place
31
43
  * @param {Map<*, Array<[*, number]>>} adjacency - nodeId -> outgoing [to, weight] edges
32
44
  * @param {number} k - Relaxation rounds / tree-size threshold, >= 1 (floored);
33
45
  * the paper's ⌊log^(1/3) n⌋
46
+ * @param {{ hops: Map<*, number>, preds: Map<*, *> }} [ties] - Canonical
47
+ * tie-break labels updated alongside dHat; fresh throwaway maps by default
34
48
  * @returns {{ pivots: Set<*>, W: Set<*> }} The pivot subset of S and the set W
35
49
  * of vertices touched below B (W always contains S)
36
50
  * @throws {Error} If k is not a number >= 1, S is empty, or any source has no
37
51
  * finite distance estimate
38
52
  */
39
- function findPivots(B, S, dHat, adjacency, k) {
53
+ function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
40
54
  if (typeof k !== "number" || Number.isNaN(k) || k < 1) {
41
55
  throw new Error("k must be a number >= 1");
42
56
  }
@@ -51,8 +65,10 @@ function findPivots(B, S, dHat, adjacency, k) {
51
65
  throw new Error("every source must have a finite distance estimate");
52
66
  }
53
67
  }
68
+ const boundKey = toBound(B);
54
69
 
55
70
  // W accumulates everything relaxed below B; layer is the paper's W_{i-1}
71
+ const sourceSet = new Set(sources);
56
72
  const W = new Set(sources);
57
73
  let layer = new Set(sources);
58
74
 
@@ -60,11 +76,12 @@ function findPivots(B, S, dHat, adjacency, k) {
60
76
  const nextLayer = new Set();
61
77
  for (const u of layer) {
62
78
  for (const [v, weight] of adjacency.get(u) ?? []) {
63
- const candidate = dHat.get(u) + weight;
64
- // Paper relaxation: <= always updates d̂; < B gates the W membership
65
- if (candidate <= (dHat.get(v) ?? Infinity)) {
66
- dHat.set(v, candidate);
67
- if (candidate < B) nextLayer.add(v);
79
+ // Canonical relaxation: d̂ updates are not gated by B (paper), and
80
+ // both improvements and exact canonical equality admit v to the
81
+ // next layer when its key is below B
82
+ const relaxed = relaxEdge(u, v, weight, dHat, ties);
83
+ if (relaxed !== null && compareKeys(relaxed.key, boundKey) < 0) {
84
+ nextLayer.add(v);
68
85
  }
69
86
  }
70
87
  }
@@ -76,28 +93,20 @@ function findPivots(B, S, dHat, adjacency, k) {
76
93
  }
77
94
  }
78
95
 
79
- // Forest F of tight edges inside W. Each vertex takes at most one parent,
80
- // chosen deterministically, so tree sizes stay well-defined even when ties
81
- // make F a DAG. Vertices on tight cycles (zero-weight cycles) all end up
82
- // with parents, so they belong to no root's tree.
96
+ // Forest of canonical predecessors inside W: every vertex of W \ S was
97
+ // (re-)labeled through some layer member during this call, so its
98
+ // recorded pred is in W and parent chains end in S
83
99
  const children = new Map();
84
- const hasParent = new Set();
85
- for (const u of W) {
86
- const du = dHat.get(u);
87
- for (const [v, weight] of adjacency.get(u) ?? []) {
88
- if (v === u || !W.has(v) || hasParent.has(v)) continue;
89
- if (dHat.get(v) === du + weight) {
90
- hasParent.add(v);
91
- if (!children.has(u)) children.set(u, []);
92
- children.get(u).push(v);
93
- }
94
- }
100
+ for (const v of W) {
101
+ if (sourceSet.has(v)) continue;
102
+ const parent = ties.preds.get(v);
103
+ if (!children.has(parent)) children.set(parent, []);
104
+ children.get(parent).push(v);
95
105
  }
96
106
 
97
- // Pivots: sources that root a tight-edge tree with >= k vertices
107
+ // Pivots: sources that root a canonical tree with >= k vertices
98
108
  const pivots = new Set();
99
109
  for (const x of sources) {
100
- if (hasParent.has(x)) continue;
101
110
  let size = 0;
102
111
  const stack = [x];
103
112
  while (stack.length > 0) {
package/src/heap.mjs CHANGED
@@ -14,11 +14,29 @@
14
14
  * duplicate-and-skip variant used inside src/dijkstra.mjs.
15
15
  */
16
16
  class MinHeap {
17
- constructor() {
17
+ /**
18
+ * @param {(a: *, b: *) => number} [compare] - Value comparator (negative
19
+ * when a < b). Defaults to numeric order, in which case values must be
20
+ * numbers; with a custom comparator values are opaque (#163 passes
21
+ * composite [length, hops, id] keys).
22
+ */
23
+ constructor(compare) {
18
24
  // entries[i] = [key, value], heap-ordered by value
19
25
  this.entries = [];
20
26
  // key -> index of that key in entries, for O(1) membership / lookup
21
27
  this.position = new Map();
28
+ this.numericValues = compare === undefined;
29
+ this.compare = compare ?? ((a, b) => (a < b ? -1 : a > b ? 1 : 0));
30
+ }
31
+
32
+ // Internal: reject non-number values, but only in default numeric mode
33
+ checkValue(value) {
34
+ if (
35
+ this.numericValues &&
36
+ (typeof value !== "number" || Number.isNaN(value))
37
+ ) {
38
+ throw new Error("value must be a number");
39
+ }
22
40
  }
23
41
 
24
42
  // Number of pairs currently stored
@@ -65,9 +83,7 @@ class MinHeap {
65
83
  * @throws {Error} If the key is already stored, or value is not a number
66
84
  */
67
85
  insert(key, value) {
68
- if (typeof value !== "number" || Number.isNaN(value)) {
69
- throw new Error("value must be a number");
70
- }
86
+ this.checkValue(value);
71
87
  if (this.position.has(key)) {
72
88
  throw new Error("key already in heap — use decreaseKey");
73
89
  }
@@ -85,14 +101,12 @@ class MinHeap {
85
101
  * @throws {Error} If the key is not stored, or value is not a number
86
102
  */
87
103
  decreaseKey(key, value) {
88
- if (typeof value !== "number" || Number.isNaN(value)) {
89
- throw new Error("value must be a number");
90
- }
104
+ this.checkValue(value);
91
105
  const index = this.position.get(key);
92
106
  if (index === undefined) {
93
107
  throw new Error("key not in heap — use insert");
94
108
  }
95
- if (this.entries[index][1] <= value) return;
109
+ if (this.compare(this.entries[index][1], value) <= 0) return;
96
110
  this.entries[index][1] = value;
97
111
  this.siftUp(index);
98
112
  }
@@ -132,7 +146,7 @@ class MinHeap {
132
146
  let i = index;
133
147
  while (i > 0) {
134
148
  const parent = (i - 1) >> 1;
135
- if (this.entries[parent][1] <= this.entries[i][1]) break;
149
+ if (this.compare(this.entries[parent][1], this.entries[i][1]) <= 0) break;
136
150
  this.swap(parent, i);
137
151
  i = parent;
138
152
  }
@@ -147,13 +161,13 @@ class MinHeap {
147
161
  let smallest = i;
148
162
  if (
149
163
  left < this.entries.length &&
150
- this.entries[left][1] < this.entries[smallest][1]
164
+ this.compare(this.entries[left][1], this.entries[smallest][1]) < 0
151
165
  ) {
152
166
  smallest = left;
153
167
  }
154
168
  if (
155
169
  right < this.entries.length &&
156
- this.entries[right][1] < this.entries[smallest][1]
170
+ this.compare(this.entries[right][1], this.entries[smallest][1]) < 0
157
171
  ) {
158
172
  smallest = right;
159
173
  }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Deterministic tie-breaking for equal-length paths (#163) — the code-level
3
+ * stand-in for the paper's Assumption 2.1 ("all paths have distinct
4
+ * lengths", "Breaking the Sorting Barrier for Directed Single-Source
5
+ * Shortest Paths").
6
+ *
7
+ * Every path is ranked by a composite key `[length, hops, id]`, compared
8
+ * lexicographically:
9
+ *
10
+ * - `length` — the ordinary path length (sum of weights). Always dominates,
11
+ * so plain distances are unaffected.
12
+ * - `hops` — the number of edges on the path (the paper's "#vertices"
13
+ * tie-break component). A zero-weight edge leaves `length` unchanged but
14
+ * always increments `hops`, so extending a path strictly increases its
15
+ * key: tight (zero-weight) cycles cannot tie with themselves and an
16
+ * equal-sum re-relaxation can never loop.
17
+ * - `id` — a final disambiguator standing in for the paper's full
18
+ * "vertex sequence" (O(1) to compare instead of O(path)): inside
19
+ * relaxation it is the candidate predecessor's id (picking the canonical
20
+ * parent among equal-(length, hops) alternatives), while for frontier
21
+ * ordering it is the vertex's own id (making the order on vertices a
22
+ * strict total order).
23
+ *
24
+ * With distinct vertex ids no two frontier keys are ever equal, which is
25
+ * exactly what Assumption 2.1 grants the paper: every BlockList pull
26
+ * separator is strict, no vertex ties with a bound, and Lemma 3.1 holds in
27
+ * its strict form — the guards that #40/#43/#161 had to add for degenerate
28
+ * ties become dead code.
29
+ *
30
+ * Scalar bounds stay expressible: `toBound(B)` maps the number B to
31
+ * `[B, -Infinity, -Infinity]`, the infimum of every real key of length B,
32
+ * so "key < toBound(B)" is exactly the public "distance strictly below B"
33
+ * contract.
34
+ *
35
+ * The canonical labels live in three maps updated together by `relaxEdge`:
36
+ * d̂ (the class's `shortestPaths`), plus `hops` and `preds` bundled as a
37
+ * `ties` object. Missing entries default to hops 0 (an externally seeded
38
+ * source is a hop-0 root) and "no predecessor" (which never loses a tie).
39
+ */
40
+
41
+ // Sentinel predecessor for sources: compares below every real id, so a
42
+ // source's own label never loses an equal-(length, hops) comparison.
43
+ const NO_PRED = -Infinity;
44
+
45
+ // Running count of compareKeys calls — the paper's cost metric ("comparisons
46
+ // between path lengths"). Every BMSSP-side comparison funnels through
47
+ // compareKeys (the heap and BlockList receive it as their comparator, and
48
+ // baseCase/findPivots/bmssp call it directly), so this one counter covers the
49
+ // whole algorithm. The benchmark harness (#170) reads it to measure the
50
+ // sorting barrier; one unconditional increment keeps the hot path branch-free.
51
+ let comparisonCount = 0;
52
+
53
+ /** Reset the compareKeys call counter to zero (benchmark instrumentation). */
54
+ function resetComparisonCount() {
55
+ comparisonCount = 0;
56
+ }
57
+
58
+ /**
59
+ * Number of compareKeys calls since the last reset (benchmark
60
+ * instrumentation).
61
+ * @returns {number}
62
+ */
63
+ function getComparisonCount() {
64
+ return comparisonCount;
65
+ }
66
+
67
+ /**
68
+ * Lexicographic comparison of two composite keys.
69
+ * @param {[number, number, *]} a - [length, hops, id]
70
+ * @param {[number, number, *]} b - [length, hops, id]
71
+ * @returns {number} Negative when a < b, positive when a > b, 0 when equal
72
+ */
73
+ function compareKeys(a, b) {
74
+ comparisonCount += 1;
75
+ if (a[0] !== b[0]) return a[0] < b[0] ? -1 : 1;
76
+ if (a[1] !== b[1]) return a[1] < b[1] ? -1 : 1;
77
+ if (a[2] !== b[2]) return a[2] < b[2] ? -1 : 1;
78
+ return 0;
79
+ }
80
+
81
+ /**
82
+ * Normalize a bound to a composite key. A scalar B becomes the infimum of
83
+ * all keys of length B, preserving the strict "distance < B" contract;
84
+ * a composite bound (an array) passes through unchanged.
85
+ * @param {number|[number, number, *]} B
86
+ * @returns {[number, number, *]}
87
+ */
88
+ function toBound(B) {
89
+ return typeof B === "number" ? [B, -Infinity, -Infinity] : B;
90
+ }
91
+
92
+ /**
93
+ * Bundle (or create) the tie-break label maps that accompany d̂.
94
+ * @param {Map<*, number>} [hops] - Canonical path edge counts
95
+ * @param {Map<*, *>} [preds] - Canonical predecessor of each vertex
96
+ * @returns {{ hops: Map<*, number>, preds: Map<*, *> }}
97
+ */
98
+ function makeTies(hops = new Map(), preds = new Map()) {
99
+ return { hops, preds };
100
+ }
101
+
102
+ /**
103
+ * The frontier-ordering key of a vertex under its current labels.
104
+ * @param {*} v - Vertex id
105
+ * @param {Map<*, number>} dHat - Distance estimates d̂[·]
106
+ * @param {{ hops: Map<*, number> }} ties - Tie-break labels
107
+ * @returns {[number, number, *]} [d̂[v], hops[v], v]
108
+ */
109
+ function orderKey(v, dHat, ties) {
110
+ return [dHat.get(v) ?? Infinity, ties.hops.get(v) ?? 0, v];
111
+ }
112
+
113
+ /**
114
+ * Attempt the canonical relaxation of edge (u, v). The candidate path
115
+ * (canonical path to u, then this edge) has path key
116
+ * [d̂[u] + w, hops[u] + 1, u]; it wins iff it is strictly smaller than v's
117
+ * current path key [d̂[v], hops[v], preds[v]] — so d̂, hops and preds move
118
+ * together and the chosen predecessor is deterministic regardless of edge
119
+ * or iteration order. Improvements strictly decrease v's path key, which is
120
+ * bounded below, so chains of relaxations always terminate (no zero-weight
121
+ * cycle can loop).
122
+ *
123
+ * The exact-equality case — the candidate IS v's current canonical label —
124
+ * is reported separately as `improved: false`. It is the paper's `≤`
125
+ * re-relaxation made canonical: when a caller completes u and relaxes its
126
+ * edges, an equal result on (u, v) means u is v's recorded label-setter and
127
+ * v may need to be re-enqueued at this level (it was labeled by a deeper
128
+ * call that did not complete it). Only the one canonical predecessor
129
+ * triggers this, so re-enqueues are deterministic and never duplicated by
130
+ * tied alternative parents; callers filter already-completed vertices to
131
+ * keep the re-enqueue finite, exactly like the paper.
132
+ *
133
+ * @param {*} u - Edge tail (its labels are read)
134
+ * @param {*} v - Edge head (its labels may be updated)
135
+ * @param {number} weight - Edge weight, >= 0
136
+ * @param {Map<*, number>} dHat - Distance estimates d̂[·], updated in place
137
+ * @param {{ hops: Map<*, number>, preds: Map<*, *> }} ties - Updated in place
138
+ * @param {[number, number, *]} [bound] - Optional gate: skip (no d̂ update)
139
+ * unless the resulting order key would be strictly below this bound
140
+ * @returns {{ key: [number, number, *], improved: boolean }|null} v's order
141
+ * key [d̂[v], hops[v], v] with `improved: true` when the labels were
142
+ * updated, `improved: false` when the candidate exactly matches v's
143
+ * canonical label; null when the candidate loses (or the bound gates it)
144
+ */
145
+ function relaxEdge(u, v, weight, dHat, ties, bound) {
146
+ const length = dHat.get(u) + weight;
147
+ const hopCount = (ties.hops.get(u) ?? 0) + 1;
148
+ if (bound !== undefined && compareKeys([length, hopCount, v], bound) >= 0) {
149
+ return null;
150
+ }
151
+ const currentPathKey = [
152
+ dHat.get(v) ?? Infinity,
153
+ ties.hops.get(v) ?? 0,
154
+ ties.preds.has(v) ? ties.preds.get(v) : NO_PRED,
155
+ ];
156
+ const cmp = compareKeys([length, hopCount, u], currentPathKey);
157
+ if (cmp > 0) return null;
158
+ if (cmp === 0) return { key: [length, hopCount, v], improved: false };
159
+ dHat.set(v, length);
160
+ ties.hops.set(v, hopCount);
161
+ ties.preds.set(v, u);
162
+ return { key: [length, hopCount, v], improved: true };
163
+ }
164
+
165
+ export {
166
+ compareKeys,
167
+ toBound,
168
+ makeTies,
169
+ orderKey,
170
+ relaxEdge,
171
+ NO_PRED,
172
+ resetComparisonCount,
173
+ getComparisonCount,
174
+ };