bmssp 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,142 @@
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
+ /**
46
+ * Lexicographic comparison of two composite keys.
47
+ * @param {[number, number, *]} a - [length, hops, id]
48
+ * @param {[number, number, *]} b - [length, hops, id]
49
+ * @returns {number} Negative when a < b, positive when a > b, 0 when equal
50
+ */
51
+ function compareKeys(a, b) {
52
+ if (a[0] !== b[0]) return a[0] < b[0] ? -1 : 1;
53
+ if (a[1] !== b[1]) return a[1] < b[1] ? -1 : 1;
54
+ if (a[2] !== b[2]) return a[2] < b[2] ? -1 : 1;
55
+ return 0;
56
+ }
57
+
58
+ /**
59
+ * Normalize a bound to a composite key. A scalar B becomes the infimum of
60
+ * all keys of length B, preserving the strict "distance < B" contract;
61
+ * a composite bound (an array) passes through unchanged.
62
+ * @param {number|[number, number, *]} B
63
+ * @returns {[number, number, *]}
64
+ */
65
+ function toBound(B) {
66
+ return typeof B === "number" ? [B, -Infinity, -Infinity] : B;
67
+ }
68
+
69
+ /**
70
+ * Bundle (or create) the tie-break label maps that accompany d̂.
71
+ * @param {Map<*, number>} [hops] - Canonical path edge counts
72
+ * @param {Map<*, *>} [preds] - Canonical predecessor of each vertex
73
+ * @returns {{ hops: Map<*, number>, preds: Map<*, *> }}
74
+ */
75
+ function makeTies(hops = new Map(), preds = new Map()) {
76
+ return { hops, preds };
77
+ }
78
+
79
+ /**
80
+ * The frontier-ordering key of a vertex under its current labels.
81
+ * @param {*} v - Vertex id
82
+ * @param {Map<*, number>} dHat - Distance estimates d̂[·]
83
+ * @param {{ hops: Map<*, number> }} ties - Tie-break labels
84
+ * @returns {[number, number, *]} [d̂[v], hops[v], v]
85
+ */
86
+ function orderKey(v, dHat, ties) {
87
+ return [dHat.get(v) ?? Infinity, ties.hops.get(v) ?? 0, v];
88
+ }
89
+
90
+ /**
91
+ * Attempt the canonical relaxation of edge (u, v). The candidate path
92
+ * (canonical path to u, then this edge) has path key
93
+ * [d̂[u] + w, hops[u] + 1, u]; it wins iff it is strictly smaller than v's
94
+ * current path key [d̂[v], hops[v], preds[v]] — so d̂, hops and preds move
95
+ * together and the chosen predecessor is deterministic regardless of edge
96
+ * or iteration order. Improvements strictly decrease v's path key, which is
97
+ * bounded below, so chains of relaxations always terminate (no zero-weight
98
+ * cycle can loop).
99
+ *
100
+ * The exact-equality case — the candidate IS v's current canonical label —
101
+ * is reported separately as `improved: false`. It is the paper's `≤`
102
+ * re-relaxation made canonical: when a caller completes u and relaxes its
103
+ * edges, an equal result on (u, v) means u is v's recorded label-setter and
104
+ * v may need to be re-enqueued at this level (it was labeled by a deeper
105
+ * call that did not complete it). Only the one canonical predecessor
106
+ * triggers this, so re-enqueues are deterministic and never duplicated by
107
+ * tied alternative parents; callers filter already-completed vertices to
108
+ * keep the re-enqueue finite, exactly like the paper.
109
+ *
110
+ * @param {*} u - Edge tail (its labels are read)
111
+ * @param {*} v - Edge head (its labels may be updated)
112
+ * @param {number} weight - Edge weight, >= 0
113
+ * @param {Map<*, number>} dHat - Distance estimates d̂[·], updated in place
114
+ * @param {{ hops: Map<*, number>, preds: Map<*, *> }} ties - Updated in place
115
+ * @param {[number, number, *]} [bound] - Optional gate: skip (no d̂ update)
116
+ * unless the resulting order key would be strictly below this bound
117
+ * @returns {{ key: [number, number, *], improved: boolean }|null} v's order
118
+ * key [d̂[v], hops[v], v] with `improved: true` when the labels were
119
+ * updated, `improved: false` when the candidate exactly matches v's
120
+ * canonical label; null when the candidate loses (or the bound gates it)
121
+ */
122
+ function relaxEdge(u, v, weight, dHat, ties, bound) {
123
+ const length = dHat.get(u) + weight;
124
+ const hopCount = (ties.hops.get(u) ?? 0) + 1;
125
+ if (bound !== undefined && compareKeys([length, hopCount, v], bound) >= 0) {
126
+ return null;
127
+ }
128
+ const currentPathKey = [
129
+ dHat.get(v) ?? Infinity,
130
+ ties.hops.get(v) ?? 0,
131
+ ties.preds.has(v) ? ties.preds.get(v) : NO_PRED,
132
+ ];
133
+ const cmp = compareKeys([length, hopCount, u], currentPathKey);
134
+ if (cmp > 0) return null;
135
+ if (cmp === 0) return { key: [length, hopCount, v], improved: false };
136
+ dHat.set(v, length);
137
+ ties.hops.set(v, hopCount);
138
+ ties.preds.set(v, u);
139
+ return { key: [length, hopCount, v], improved: true };
140
+ }
141
+
142
+ export { compareKeys, toBound, makeTies, orderKey, relaxEdge, NO_PRED };