bmssp 1.1.1 → 1.2.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.
package/README.md CHANGED
@@ -36,6 +36,8 @@ with every building block shipped, tested, and released individually:
36
36
  | Opt-in constant-degree transform, in/out-degree ≤ 2 ([#164](https://github.com/Sirivasv/bmssp-js/issues/164)) | `constantDegreeTransform()` | ✅ done |
37
37
  | BMSSP-vs-Dijkstra head-to-head in the benchmark harness ([#170](https://github.com/Sirivasv/bmssp-js/issues/170)) | `npm run bench` / `npm run bench:counts` | ✅ done |
38
38
  | Performance-cliff investigation; quadratic `BatchPrepend` fixed ([#182](https://github.com/Sirivasv/bmssp-js/issues/182)) | `src/blockList.mjs` | ✅ done — **1.1.1** |
39
+ | Exact Lemma 3.3 asymptotics: balanced-BST bound index + linear-time selection ([#167](https://github.com/Sirivasv/bmssp-js/issues/167)) | `src/boundIndex.mjs` + `src/select.mjs` | ✅ done |
40
+ | Relaxation micro-optimizations: allocation-free hot loops, −13–23% wall-clock ([#168](https://github.com/Sirivasv/bmssp-js/issues/168)) | `src/tieBreak.mjs` + the algorithm modules | ✅ done — **1.2.0** |
39
41
 
40
42
  > **Honest note:** the paper's win is asymptotic, and this repo optimizes for correctness
41
43
  > and readability, not raw speed. Measured head-to-head (algorithm time only, graph
@@ -44,10 +46,13 @@ with every building block shipped, tested, and released individually:
44
46
  > Dijkstra still wins on wall-clock at every practical size, though the gap narrows as
45
47
  > sparse graphs grow (2.5× at 50k nodes → 1.57× at 2M). But in the paper's own metric —
46
48
  > **comparisons between path lengths** — BMSSP does **fewer comparisons than Dijkstra
47
- > from about n = 1M on sparse graphs** (`npm run bench:counts` measures the ratio falling
48
- > 1.20× at 50k → 1.03× at 200k → 0.98× at 1M; 0.91× at 2M in the record), and the
49
- > advantage grows with size: the "sorting barrier" is measurably broken; what remains is
50
- > JS constant factors. The two performance cliffs found in the 1.0.0 measurements were
49
+ > from under n = 50k on sparse graphs** (`npm run bench:counts` measures 0.95× at 50k →
50
+ > 0.76× at 200k → **0.65× at 1M**), and the advantage grows with size: the "sorting
51
+ > barrier" is measurably broken; what remains is JS constant factors. That crossover
52
+ > used to sit at ~n = 1M until [#167](https://github.com/Sirivasv/bmssp-js/issues/167)
53
+ > replaced the block structure's sort-based internals with the paper's exact machinery
54
+ > (balanced-BST bound index + deterministic linear-time selection).
55
+ > The two performance cliffs found in the 1.0.0 measurements were
51
56
  > run down in [#182](https://github.com/Sirivasv/bmssp-js/issues/182): the star-graph
52
57
  > blowup was a quadratic in `BatchPrepend`'s bookkeeping — fixed in `1.1.1` (500k-node
53
58
  > star: 61 s → ~3 s) — and the recursion-level step is an inherent, measured ~+24% per
@@ -70,8 +75,9 @@ with every building block shipped, tested, and released individually:
70
75
  closest batch without paying the Θ(log n)-per-vertex "sorting barrier."
71
76
 
72
77
  The wall-clock crossover point is astronomically large, but the asymptotics are real: in
73
- measured comparison counts this implementation already beats Dijkstra past ~1M nodes on
74
- sparse graphs ([benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md)). This repo
78
+ measured comparison counts this implementation already beats Dijkstra from under 50k
79
+ nodes on sparse graphs, by a third at 1M
80
+ ([benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md)). This repo
75
81
  optimizes for a **correct, readable, well-tested** implementation, validated line-by-line
76
82
  against a Dijkstra oracle — not for raw speed.
77
83
 
@@ -183,8 +189,8 @@ graphs in a few seconds), and set `FUZZ_XL=1` for an additional 2-million-node r
183
189
  | --- | --- | --- |
184
190
  | [`1.0.0`](https://github.com/Sirivasv/bmssp-js/milestones) | First end-to-end functional BMSSP (issues #40–#45) | ✅ done |
185
191
  | `1.1.0` | Correctness hardening — fuzz tests, edge cases, tie-breaking, input validation, constant-degree transform, API docs | ✅ done |
186
- | `1.2.0` | Performance & ergonomics — exact Lemma 3.3 asymptotics, BMSSP-vs-Dijkstra benchmarks, performance-cliff investigation | 🔨 current focus |
187
- | `2.0.0` | API generalization — public multi-source/bounded entry point, flexible inputs | planned |
192
+ | `1.2.0` | Performance & ergonomics — exact Lemma 3.3 asymptotics, BMSSP-vs-Dijkstra benchmarks, cliff investigation, relaxation micro-optimizations | done |
193
+ | `2.0.0` | API generalization — public multi-source/bounded entry point, flexible inputs | 🔨 next |
188
194
 
189
195
  ## Contributing (humans and AI agents welcome)
190
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmssp",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Javascript package implementation of the bmssp algorithm.",
5
5
  "main": "index.mjs",
6
6
  "keywords": [
package/src/baseCase.mjs CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  makeTies,
6
6
  orderKey,
7
7
  relaxEdge,
8
+ RELAX_LOST,
8
9
  } from "./tieBreak.mjs";
9
10
 
10
11
  /**
@@ -76,18 +77,25 @@ function baseCase(B, S, dHat, adjacency, k, ties = makeTies()) {
76
77
  while (!heap.isEmpty() && settled.size < cap + 1) {
77
78
  const { key: u } = heap.extractMin();
78
79
  settled.add(u);
79
- for (const [v, weight] of adjacency.get(u) ?? []) {
80
+ const edges = adjacency.get(u);
81
+ if (edges === undefined) continue;
82
+ for (let i = 0; i < edges.length; i += 1) {
83
+ const edge = edges[i];
84
+ const v = edge[0];
80
85
  // Canonical relaxation, gated by the bound (the paper's `< B`). An
81
86
  // exact-equality result means u is v's recorded label-setter (v was
82
87
  // labeled by an earlier phase without being completed) and v must be
83
88
  // (re-)enqueued — unless this very call already settled it, which is
84
89
  // what keeps zero-weight plateaus finite.
85
- const relaxed = relaxEdge(u, v, weight, dHat, ties, boundKey);
86
- if (relaxed === null || settled.has(v)) continue;
90
+ const result = relaxEdge(u, v, edge[1], dHat, ties, boundKey);
91
+ if (result === RELAX_LOST || settled.has(v)) continue;
92
+ // Non-lost ⇒ v's stored label IS the candidate; materialize its key
93
+ // only here, on the enqueue path (#168)
94
+ const key = orderKey(v, dHat, ties);
87
95
  if (heap.has(v)) {
88
- heap.decreaseKey(v, relaxed.key);
96
+ heap.decreaseKey(v, key);
89
97
  } else {
90
- heap.insert(v, relaxed.key);
98
+ heap.insert(v, key);
91
99
  }
92
100
  }
93
101
  }
package/src/blockList.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import { BoundIndex } from "./boundIndex.mjs";
2
+ import { partitionByRank } from "./select.mjs";
3
+
1
4
  /**
2
5
  * Block-based "partial-sort" structure D from Lemma 3.3 of the BMSSP paper
3
6
  * ("Breaking the Sorting Barrier for Directed Single-Source Shortest Paths").
@@ -11,12 +14,16 @@
11
14
  * Internal layout:
12
15
  * - d1: blocks filled by insert(). Each block carries an upper bound on its
13
16
  * values; bounds are non-decreasing across blocks, and the last block
14
- * always has bound B so every value < B has a home.
15
- * - d0: blocks filled by batchPrepend() only; they conceptually sit in front
16
- * of d1 (their values are smaller than everything inserted so far).
17
+ * always has bound B so every value < B has a home. The sequence lives in
18
+ * a self-balancing BST (BoundIndex) searched through the monotone bounds.
19
+ * - d0: blocks filled by batchPrepend() only, kept as a doubly-linked list;
20
+ * they conceptually sit in front of d1 (their values are smaller than
21
+ * everything inserted so far).
17
22
  *
18
- * The block-bound index is a plain array searched with binary search instead
19
- * of the paper's balanced BST same behavior, worse constants (issue #167).
23
+ * Since #167 the structure meets Lemma 3.3's exact bounds: the bound index
24
+ * is a balanced BST (O(log #blocks) search/split/drop instead of O(#blocks)
25
+ * array splices) and splits/chunking/pulls use deterministic linear-time
26
+ * selection (partitionByRank) instead of an O(M log M) sort.
20
27
  */
21
28
  class BlockList {
22
29
  /**
@@ -35,17 +42,26 @@ class BlockList {
35
42
  this.M = Math.floor(M);
36
43
  this.B = B;
37
44
  this.compare = compare ?? ((a, b) => (a < b ? -1 : a > b ? 1 : 0));
38
- // d1 starts as a single empty block with upper bound B
39
- this.d1 = [this.makeBlock(B)];
40
- this.d0 = [];
45
+ // Shared [key, value]-pair comparator for splits/chunking/pulls, hoisted
46
+ // so the hot paths don't allocate a closure per call
47
+ this.compareBySecond = (a, b) => this.compare(a[1], b[1]);
48
+ // d1 starts as a single empty block with upper bound B; that block is
49
+ // kept as the sequence's last forever so every value < B has a home
50
+ this.d1 = new BoundIndex();
51
+ this.lastD1Block = this.makeBlock(this.B);
52
+ this.lastD1Block.node = this.d1.append(this.lastD1Block);
53
+ // d0 is a doubly-linked list of blocks; the head holds the smallest values
54
+ this.d0Head = null;
41
55
  // key -> block currently holding that key, for O(1) duplicate handling
42
56
  this.locator = new Map();
43
57
  this.count = 0;
44
58
  }
45
59
 
46
- // Internal: create an empty block with the given value upper bound
60
+ // Internal: create an empty block with the given value upper bound.
61
+ // node is the handle in the d1 BoundIndex (null for d0 blocks);
62
+ // prev/next thread the d0 linked list (null for d1 blocks).
47
63
  makeBlock(bound) {
48
- return { bound, entries: new Map() };
64
+ return { bound, entries: new Map(), node: null, prev: null, next: null };
49
65
  }
50
66
 
51
67
  // Number of pairs currently stored
@@ -73,43 +89,37 @@ class BlockList {
73
89
  if (this.compare(holder.entries.get(key), value) <= 0) return;
74
90
  this.removeKey(key, holder);
75
91
  }
76
- // Binary-search d1 for the first block whose bound covers the value
77
- let lo = 0;
78
- let hi = this.d1.length - 1;
79
- while (lo < hi) {
80
- const mid = (lo + hi) >> 1;
81
- if (this.compare(this.d1[mid].bound, value) >= 0) {
82
- hi = mid;
83
- } else {
84
- lo = mid + 1;
85
- }
86
- }
87
- const block = this.d1[lo];
92
+ // First d1 block whose bound covers the value: bounds are monotone along
93
+ // the sequence, so this predicate search is the paper's O(log #blocks)
94
+ // BST lookup. It always succeeds — the last block's bound is B > value.
95
+ const node = this.d1.findFirst(
96
+ (candidate) => this.compare(candidate.bound, value) >= 0,
97
+ );
98
+ const block = node.item;
88
99
  block.entries.set(key, value);
89
100
  this.locator.set(key, block);
90
101
  this.count += 1;
91
102
  if (block.entries.size > this.M) {
92
- this.splitBlock(lo);
103
+ this.splitBlock(block);
93
104
  }
94
105
  }
95
106
 
96
107
  // Internal: split an overfull d1 block into two halves around its median
97
- // value. The lower half gets bound = its own max value; the upper half
98
- // keeps the original bound, so inter-block ordering is preserved.
99
- // (The paper uses linear-time median selection; sorting is O(M log M) but
100
- // simpler — acceptable for this correctness-first implementation.)
101
- splitBlock(index) {
102
- const block = this.d1[index];
103
- const sorted = [...block.entries].sort((a, b) => this.compare(a[1], b[1]));
104
- const half = sorted.length >> 1;
105
- const lower = this.makeBlock(sorted[half - 1][1]);
108
+ // value (deterministic linear-time selection, as Lemma 3.3 prescribes).
109
+ // The lower half gets bound = the median (its own max value); the upper
110
+ // half keeps the original bound, so inter-block ordering is preserved.
111
+ splitBlock(block) {
112
+ const pairs = [...block.entries];
113
+ const half = pairs.length >> 1;
114
+ partitionByRank(pairs, half - 1, this.compareBySecond);
115
+ const lower = this.makeBlock(pairs[half - 1][1]);
106
116
  for (let i = 0; i < half; i += 1) {
107
- const [key, value] = sorted[i];
117
+ const [key, value] = pairs[i];
108
118
  block.entries.delete(key);
109
119
  lower.entries.set(key, value);
110
120
  this.locator.set(key, lower);
111
121
  }
112
- this.d1.splice(index, 0, lower);
122
+ lower.node = this.d1.insertBefore(block.node, lower);
113
123
  }
114
124
 
115
125
  /**
@@ -144,23 +154,34 @@ class BlockList {
144
154
  fresh.push([key, value]);
145
155
  }
146
156
  if (fresh.length === 0) return;
147
- // One block if the batch fits, otherwise sorted chunks of <= ceil(M/2)
157
+ // One block if the batch fits; otherwise value-ordered chunks of
158
+ // <= ceil(M/2) — O(|L|/M) blocks built with O(|L|·max{1, log(|L|/M)})
159
+ // comparisons, the Lemma 3.3 bound
148
160
  let chunks;
149
161
  if (fresh.length <= this.M) {
150
162
  chunks = [fresh];
151
163
  } else {
152
- fresh.sort((a, b) => this.compare(a[1], b[1]));
153
164
  const chunkSize = Math.ceil(this.M / 2);
154
165
  chunks = [];
155
- for (let i = 0; i < fresh.length; i += chunkSize) {
156
- chunks.push(fresh.slice(i, i + chunkSize));
166
+ if (fresh.length >= chunkSize * chunkSize) {
167
+ // Many chunks (|L| >= chunkSize², e.g. the M = 1 star regime, where
168
+ // |L|/M ~ |L|): sorting's O(|L| log |L|) is within 2x of the
169
+ // O(|L| log(|L|/chunkSize)) target — |L| >= c² gives
170
+ // log |L| <= 2 log(|L|/c) — and a single sort is much faster than
171
+ // log(|L|/M) rounds of median selection
172
+ fresh.sort(this.compareBySecond);
173
+ for (let i = 0; i < fresh.length; i += chunkSize) {
174
+ chunks.push(fresh.slice(i, i + chunkSize));
175
+ }
176
+ } else {
177
+ // Few chunks (|L| < chunkSize²): repeated median splitting — here
178
+ // the recursion is at most log2(chunkSize) levels deep, and a sort
179
+ // would overshoot the Lemma bound (up to O(|L| log |L|) for
180
+ // O(|L| log(|L|/M)) with |L| close to M)
181
+ this.chunkByMedian(fresh, chunkSize, chunks);
157
182
  }
158
183
  }
159
- // Materialize the chunks as blocks (ascending, so chunk 0 — the smallest
160
- // values — ends up frontmost) and prepend them all in one concat. A
161
- // per-chunk unshift here re-shifts the whole d0 array every time: with a
162
- // small M the chunk count approaches |L| and the loop turns quadratic —
163
- // the #182 star-graph blowup (~n single-entry chunks at M = 1).
184
+ // Materialize the chunks as blocks (ascending order)...
164
185
  const blocks = [];
165
186
  for (const chunk of chunks) {
166
187
  // Seed the block bound with the first value, then max-update — avoids
@@ -174,7 +195,28 @@ class BlockList {
174
195
  }
175
196
  blocks.push(block);
176
197
  }
177
- this.d0 = blocks.concat(this.d0);
198
+ // ...and link them in front of d0, smallest chunk becoming the new head
199
+ let head = this.d0Head;
200
+ for (let i = blocks.length - 1; i >= 0; i -= 1) {
201
+ const block = blocks[i];
202
+ block.next = head;
203
+ if (head !== null) head.prev = block;
204
+ head = block;
205
+ }
206
+ this.d0Head = head;
207
+ }
208
+
209
+ // Internal: recursively median-split pairs (in place / via slices) into
210
+ // value-ordered chunks of at most maxSize, appended to out ascending
211
+ chunkByMedian(pairs, maxSize, out) {
212
+ if (pairs.length <= maxSize) {
213
+ out.push(pairs);
214
+ return;
215
+ }
216
+ const half = pairs.length >> 1;
217
+ partitionByRank(pairs, half - 1, this.compareBySecond);
218
+ this.chunkByMedian(pairs.slice(0, half), maxSize, out);
219
+ this.chunkByMedian(pairs.slice(half), maxSize, out);
178
220
  }
179
221
 
180
222
  /**
@@ -189,8 +231,10 @@ class BlockList {
189
231
  if (this.count <= this.M) {
190
232
  // Everything fits in one batch: drain the structure and reset it
191
233
  const keys = new Set(this.locator.keys());
192
- this.d0 = [];
193
- this.d1 = [this.makeBlock(this.B)];
234
+ this.d0Head = null;
235
+ this.d1.clear();
236
+ this.lastD1Block = this.makeBlock(this.B);
237
+ this.lastD1Block.node = this.d1.append(this.lastD1Block);
194
238
  this.locator.clear();
195
239
  this.count = 0;
196
240
  return { keys, bound: this.B };
@@ -198,18 +242,31 @@ class BlockList {
198
242
  // Collect prefix blocks from each sequence until that side holds >= M
199
243
  // candidate elements (or runs out). The M smallest overall are in there.
200
244
  const candidates = [];
201
- for (const seq of [this.d0, this.d1]) {
202
- let collected = 0;
203
- for (const block of seq) {
204
- if (collected >= this.M) break;
205
- for (const [key, value] of block.entries) {
206
- candidates.push([key, value, block]);
207
- }
208
- collected += block.entries.size;
245
+ let collected = 0;
246
+ for (
247
+ let block = this.d0Head;
248
+ block !== null && collected < this.M;
249
+ block = block.next
250
+ ) {
251
+ for (const [key, value] of block.entries) {
252
+ candidates.push([key, value, block]);
209
253
  }
254
+ collected += block.entries.size;
210
255
  }
211
- // Take the M smallest candidates out of the structure
212
- candidates.sort((a, b) => this.compare(a[1], b[1]));
256
+ collected = 0;
257
+ for (
258
+ let node = this.d1.first();
259
+ node !== null && collected < this.M;
260
+ node = this.d1.next(node)
261
+ ) {
262
+ for (const [key, value] of node.item.entries) {
263
+ candidates.push([key, value, node.item]);
264
+ }
265
+ collected += node.item.entries.size;
266
+ }
267
+ // Move the M smallest candidates to the front (linear-time selection,
268
+ // the Lemma 3.3 O(M) pull) and take them out of the structure
269
+ partitionByRank(candidates, this.M - 1, this.compareBySecond);
213
270
  const keys = new Set();
214
271
  for (let i = 0; i < this.M; i += 1) {
215
272
  const [key, , block] = candidates[i];
@@ -222,12 +279,20 @@ class BlockList {
222
279
  // Under a strict total order (#163's composite keys) this separator is
223
280
  // strictly above every pulled value — no boundary ties.
224
281
  let bound = null;
225
- for (const seq of [this.d0, this.d1]) {
226
- const block = seq.find((b) => b.entries.size > 0);
227
- if (block) {
282
+ for (let block = this.d0Head; block !== null; block = block.next) {
283
+ if (block.entries.size > 0) {
228
284
  for (const value of block.entries.values()) {
229
285
  if (bound === null || this.compare(value, bound) < 0) bound = value;
230
286
  }
287
+ break;
288
+ }
289
+ }
290
+ for (let node = this.d1.first(); node !== null; node = this.d1.next(node)) {
291
+ if (node.item.entries.size > 0) {
292
+ for (const value of node.item.entries.values()) {
293
+ if (bound === null || this.compare(value, bound) < 0) bound = value;
294
+ }
295
+ break;
231
296
  }
232
297
  }
233
298
  return { keys, bound };
@@ -244,18 +309,18 @@ class BlockList {
244
309
  }
245
310
  }
246
311
 
247
- // Internal: physically remove an emptied block. The last d1 block (bound B)
248
- // is kept even when empty so insert always finds a home for any value < B.
312
+ // Internal: physically remove an emptied block from its sequence an
313
+ // O(log #blocks) BST removal for d1, an O(1) unlink for d0. The last d1
314
+ // block (bound B) is kept even when empty so insert always finds a home.
249
315
  dropIfEmpty(block) {
250
- const i0 = this.d0.indexOf(block);
251
- if (i0 !== -1) {
252
- this.d0.splice(i0, 1);
316
+ if (block.node !== null) {
317
+ if (block === this.lastD1Block) return;
318
+ this.d1.remove(block.node);
253
319
  return;
254
320
  }
255
- const i1 = this.d1.indexOf(block);
256
- if (i1 !== -1 && i1 < this.d1.length - 1) {
257
- this.d1.splice(i1, 1);
258
- }
321
+ if (block.prev !== null) block.prev.next = block.next;
322
+ else this.d0Head = block.next;
323
+ if (block.next !== null) block.next.prev = block.prev;
259
324
  }
260
325
  }
261
326
 
package/src/bmssp.mjs CHANGED
@@ -3,10 +3,12 @@ import { findPivots } from "./findPivots.mjs";
3
3
  import { BlockList } from "./blockList.mjs";
4
4
  import {
5
5
  compareKeys,
6
+ compareKeyParts,
6
7
  toBound,
7
8
  makeTies,
8
9
  orderKey,
9
10
  relaxEdge,
11
+ RELAX_LOST,
10
12
  } from "./tieBreak.mjs";
11
13
 
12
14
  class BMSSP {
@@ -224,19 +226,26 @@ class BMSSP {
224
226
  // without completing (the paper's `≤` relaxation, deterministic here:
225
227
  // only the recorded label-setter triggers it); vertices already
226
228
  // completed at this level are filtered so re-enqueues stay finite.
229
+ // A non-lost relaxation leaves v's stored label equal to the
230
+ // candidate, so the band tests compare the unpacked stored components
231
+ // and a key array is built only for the enqueue itself (#168).
227
232
  const K = [];
228
233
  for (const u of Ui) {
229
- for (const [v, weight] of this.adjacency.get(u) ?? []) {
230
- const relaxed = relaxEdge(u, v, weight, dHat, ties);
231
- if (relaxed === null || U.has(v)) continue;
232
- const key = relaxed.key;
233
- if (compareKeys(key, BiKey) >= 0 && compareKeys(key, boundKey) < 0) {
234
- D.insert(v, key);
235
- } else if (
236
- compareKeys(key, BiPrimeKey) >= 0 &&
237
- compareKeys(key, BiKey) < 0
238
- ) {
239
- K.push([v, key]);
234
+ const edges = this.adjacency.get(u);
235
+ if (edges === undefined) continue;
236
+ for (let i = 0; i < edges.length; i += 1) {
237
+ const edge = edges[i];
238
+ const v = edge[0];
239
+ const result = relaxEdge(u, v, edge[1], dHat, ties);
240
+ if (result === RELAX_LOST || U.has(v)) continue;
241
+ const length = dHat.get(v);
242
+ const hopCount = ties.hops.get(v) ?? 0;
243
+ if (compareKeyParts(length, hopCount, v, BiKey) >= 0) {
244
+ if (compareKeyParts(length, hopCount, v, boundKey) < 0) {
245
+ D.insert(v, [length, hopCount, v]);
246
+ }
247
+ } else if (compareKeyParts(length, hopCount, v, BiPrimeKey) >= 0) {
248
+ K.push([v, [length, hopCount, v]]);
240
249
  }
241
250
  }
242
251
  }
@@ -244,9 +253,13 @@ class BMSSP {
244
253
  // go back in front of everything else
245
254
  for (const x of Si) {
246
255
  if (U.has(x)) continue;
247
- const key = orderKey(x, dHat, ties);
248
- if (compareKeys(key, BiPrimeKey) >= 0 && compareKeys(key, BiKey) < 0) {
249
- K.push([x, key]);
256
+ const length = dHat.get(x) ?? Infinity;
257
+ const hopCount = ties.hops.get(x) ?? 0;
258
+ if (
259
+ compareKeyParts(length, hopCount, x, BiKey) < 0 &&
260
+ compareKeyParts(length, hopCount, x, BiPrimeKey) >= 0
261
+ ) {
262
+ K.push([x, [length, hopCount, x]]);
250
263
  }
251
264
  }
252
265
  if (K.length > 0) D.batchPrepend(K);
@@ -256,7 +269,10 @@ class BMSSP {
256
269
  const finalKey =
257
270
  compareKeys(lastBoundKey, boundKey) < 0 ? lastBoundKey : boundKey;
258
271
  for (const x of W) {
259
- if (compareKeys(orderKey(x, dHat, ties), finalKey) < 0) U.add(x);
272
+ const length = dHat.get(x) ?? Infinity;
273
+ if (compareKeyParts(length, ties.hops.get(x) ?? 0, x, finalKey) < 0) {
274
+ U.add(x);
275
+ }
260
276
  }
261
277
  return finish(finalKey, U);
262
278
  }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Self-balancing (AVL) ordered sequence — the "balanced BST over block upper
3
+ * bounds" of Lemma 3.3 (issue #167; replaces BlockList's plain-array bound
4
+ * index, whose splice-based maintenance paid O(#blocks) per split/drop).
5
+ *
6
+ * BlockList keeps its d1 blocks with non-decreasing value bounds. This tree
7
+ * stores that block sequence POSITIONALLY: a node's in-order position is its
8
+ * sequence position, and the tree itself never compares items. Because the
9
+ * bounds are monotone along the sequence, findFirst with a monotone
10
+ * predicate binary-searches the sequence in O(log size) — the paper's bound
11
+ * lookup — while insertBefore / append / remove stay O(log size) via AVL
12
+ * rebalancing.
13
+ *
14
+ * Nodes are plain objects { item, parent, left, right, height } returned as
15
+ * handles; callers keep the handle to later remove or iterate from it.
16
+ */
17
+ class BoundIndex {
18
+ constructor() {
19
+ this.root = null;
20
+ this.count = 0;
21
+ }
22
+
23
+ // Number of items currently stored
24
+ get size() {
25
+ return this.count;
26
+ }
27
+
28
+ // Drop every item
29
+ clear() {
30
+ this.root = null;
31
+ this.count = 0;
32
+ }
33
+
34
+ // First node in sequence order, or null when empty
35
+ first() {
36
+ let node = this.root;
37
+ if (node === null) return null;
38
+ while (node.left !== null) node = node.left;
39
+ return node;
40
+ }
41
+
42
+ // Last node in sequence order, or null when empty
43
+ last() {
44
+ let node = this.root;
45
+ if (node === null) return null;
46
+ while (node.right !== null) node = node.right;
47
+ return node;
48
+ }
49
+
50
+ // In-order successor of a node, or null at the end of the sequence
51
+ next(node) {
52
+ if (node.right !== null) {
53
+ let succ = node.right;
54
+ while (succ.left !== null) succ = succ.left;
55
+ return succ;
56
+ }
57
+ let child = node;
58
+ let parent = child.parent;
59
+ while (parent !== null && parent.right === child) {
60
+ child = parent;
61
+ parent = parent.parent;
62
+ }
63
+ return parent;
64
+ }
65
+
66
+ /**
67
+ * Leftmost node whose item satisfies the predicate, or null when none
68
+ * does. The predicate must be monotone along the sequence (a prefix of
69
+ * false followed by a suffix of true) — with BlockList's non-decreasing
70
+ * bounds, `(block) => compare(block.bound, value) >= 0` is exactly that.
71
+ * O(log size).
72
+ * @param {(item: *) => boolean} predicate
73
+ * @returns {*} The matching node handle, or null
74
+ */
75
+ findFirst(predicate) {
76
+ let node = this.root;
77
+ let found = null;
78
+ while (node !== null) {
79
+ if (predicate(node.item)) {
80
+ found = node;
81
+ node = node.left;
82
+ } else {
83
+ node = node.right;
84
+ }
85
+ }
86
+ return found;
87
+ }
88
+
89
+ /**
90
+ * Insert an item at the end of the sequence. O(log size).
91
+ * @returns {*} The new node handle
92
+ */
93
+ append(item) {
94
+ const node = this.makeNode(item);
95
+ if (this.root === null) {
96
+ this.root = node;
97
+ } else {
98
+ const tail = this.last();
99
+ tail.right = node;
100
+ node.parent = tail;
101
+ this.rebalance(tail);
102
+ }
103
+ this.count += 1;
104
+ return node;
105
+ }
106
+
107
+ /**
108
+ * Insert an item immediately before an existing node. O(log size).
109
+ * @param {*} reference - Node handle the new item goes in front of
110
+ * @returns {*} The new node handle
111
+ */
112
+ insertBefore(reference, item) {
113
+ const node = this.makeNode(item);
114
+ if (reference.left === null) {
115
+ reference.left = node;
116
+ node.parent = reference;
117
+ } else {
118
+ let pred = reference.left;
119
+ while (pred.right !== null) pred = pred.right;
120
+ pred.right = node;
121
+ node.parent = pred;
122
+ }
123
+ this.count += 1;
124
+ this.rebalance(node.parent);
125
+ return node;
126
+ }
127
+
128
+ /**
129
+ * Remove a node from the sequence. O(log size).
130
+ * @param {*} node - Node handle previously returned by append/insertBefore
131
+ */
132
+ remove(node) {
133
+ let start; // deepest structurally-changed node, where rebalancing begins
134
+ if (node.left === null) {
135
+ start = node.parent;
136
+ this.replaceInParent(node, node.right);
137
+ } else if (node.right === null) {
138
+ start = node.parent;
139
+ this.replaceInParent(node, node.left);
140
+ } else {
141
+ // Two children: splice out the in-order successor (which has no left
142
+ // child) and put it in the removed node's place
143
+ let succ = node.right;
144
+ while (succ.left !== null) succ = succ.left;
145
+ if (succ.parent === node) {
146
+ start = succ;
147
+ } else {
148
+ start = succ.parent;
149
+ this.replaceInParent(succ, succ.right);
150
+ succ.right = node.right;
151
+ succ.right.parent = succ;
152
+ }
153
+ this.replaceInParent(node, succ);
154
+ succ.left = node.left;
155
+ succ.left.parent = succ;
156
+ succ.height = node.height;
157
+ }
158
+ node.parent = node.left = node.right = null;
159
+ this.count -= 1;
160
+ this.rebalance(start);
161
+ }
162
+
163
+ // Internal: fresh leaf node
164
+ makeNode(item) {
165
+ return { item, parent: null, left: null, right: null, height: 1 };
166
+ }
167
+
168
+ // Internal: make replacement take node's place under node's parent
169
+ // (replacement may be null)
170
+ replaceInParent(node, replacement) {
171
+ const parent = node.parent;
172
+ if (parent === null) {
173
+ this.root = replacement;
174
+ } else if (parent.left === node) {
175
+ parent.left = replacement;
176
+ } else {
177
+ parent.right = replacement;
178
+ }
179
+ if (replacement !== null) replacement.parent = parent;
180
+ }
181
+
182
+ // Internal: height of a possibly-null subtree
183
+ heightOf(node) {
184
+ return node === null ? 0 : node.height;
185
+ }
186
+
187
+ // Internal: recompute a node's height from its children
188
+ updateHeight(node) {
189
+ node.height =
190
+ 1 + Math.max(this.heightOf(node.left), this.heightOf(node.right));
191
+ }
192
+
193
+ // Internal: left-minus-right height difference
194
+ balanceOf(node) {
195
+ return this.heightOf(node.left) - this.heightOf(node.right);
196
+ }
197
+
198
+ // Internal: walk from node to the root, updating heights and rotating
199
+ // wherever the AVL invariant |balance| <= 1 broke (deletion can require a
200
+ // rotation at every level; insertion at most one — the loop covers both)
201
+ rebalance(node) {
202
+ while (node !== null) {
203
+ this.updateHeight(node);
204
+ const balance = this.balanceOf(node);
205
+ if (balance > 1) {
206
+ if (this.balanceOf(node.left) < 0) this.rotateLeft(node.left);
207
+ node = this.rotateRight(node);
208
+ } else if (balance < -1) {
209
+ if (this.balanceOf(node.right) > 0) this.rotateRight(node.right);
210
+ node = this.rotateLeft(node);
211
+ }
212
+ node = node.parent;
213
+ }
214
+ }
215
+
216
+ // Internal: standard AVL rotation; returns the subtree's new root
217
+ rotateLeft(node) {
218
+ const pivot = node.right;
219
+ node.right = pivot.left;
220
+ if (pivot.left !== null) pivot.left.parent = node;
221
+ this.replaceInParent(node, pivot);
222
+ pivot.left = node;
223
+ node.parent = pivot;
224
+ this.updateHeight(node);
225
+ this.updateHeight(pivot);
226
+ return pivot;
227
+ }
228
+
229
+ // Internal: mirror of rotateLeft
230
+ rotateRight(node) {
231
+ const pivot = node.left;
232
+ node.left = pivot.right;
233
+ if (pivot.right !== null) pivot.right.parent = node;
234
+ this.replaceInParent(node, pivot);
235
+ pivot.right = node;
236
+ node.parent = pivot;
237
+ this.updateHeight(node);
238
+ this.updateHeight(pivot);
239
+ return pivot;
240
+ }
241
+ }
242
+
243
+ export { BoundIndex };
@@ -1,4 +1,10 @@
1
- import { compareKeys, toBound, makeTies, relaxEdge } from "./tieBreak.mjs";
1
+ import {
2
+ compareKeyParts,
3
+ toBound,
4
+ makeTies,
5
+ relaxEdge,
6
+ RELAX_LOST,
7
+ } from "./tieBreak.mjs";
2
8
 
3
9
  /**
4
10
  * FindPivots(B, S) — Algorithm 1 of "Breaking the Sorting Barrier for Directed
@@ -75,12 +81,21 @@ function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
75
81
  for (let i = 1; i <= rounds; i += 1) {
76
82
  const nextLayer = new Set();
77
83
  for (const u of layer) {
78
- for (const [v, weight] of adjacency.get(u) ?? []) {
84
+ const edges = adjacency.get(u);
85
+ if (edges === undefined) continue;
86
+ for (let j = 0; j < edges.length; j += 1) {
87
+ const edge = edges[j];
88
+ const v = edge[0];
79
89
  // Canonical relaxation: d̂ updates are not gated by B (paper), and
80
90
  // 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) {
91
+ // next layer when its key is below B. Non-lost ⇒ v's stored label
92
+ // is the candidate, so the W gate compares the unpacked stored
93
+ // components no key allocation (#168).
94
+ const result = relaxEdge(u, v, edge[1], dHat, ties);
95
+ if (
96
+ result !== RELAX_LOST &&
97
+ compareKeyParts(dHat.get(v), ties.hops.get(v) ?? 0, v, boundKey) < 0
98
+ ) {
84
99
  nextLayer.add(v);
85
100
  }
86
101
  }
package/src/select.mjs ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Deterministic worst-case-linear selection — the "linear-time median
3
+ * selection" Lemma 3.3 prescribes for BlockList block splits, batch
4
+ * chunking and pulls (issue #167; replaces the sort-based O(M log M)
5
+ * shortcut).
6
+ *
7
+ * Introselect construction: quickselect with a deterministic median-of-3
8
+ * pivot (~2–3n comparisons on typical inputs — well below a sort's n log n)
9
+ * guarded by a work budget. If pathological pivots exhaust the budget, the
10
+ * pivot switches to median-of-medians (groups of 5), whose guaranteed
11
+ * middle-40% pivot makes the remainder linear — so the whole selection is
12
+ * O(n) worst case. Median-of-medians alone would also be linear but costs
13
+ * ~10–20n comparisons, MORE than sorting at practical sizes; the budgeted
14
+ * hybrid keeps both the bound and the constant. No randomness anywhere:
15
+ * runs stay fully reproducible.
16
+ */
17
+
18
+ // Internal: swap two array slots
19
+ function swap(items, i, j) {
20
+ const tmp = items[i];
21
+ items[i] = items[j];
22
+ items[j] = tmp;
23
+ }
24
+
25
+ // Internal: insertion-sort the closed range [lo, hi] (only ever called on
26
+ // ranges of at most 5 elements)
27
+ function sortRange(items, lo, hi, compare) {
28
+ for (let i = lo + 1; i <= hi; i += 1) {
29
+ const item = items[i];
30
+ let j = i - 1;
31
+ while (j >= lo && compare(items[j], item) > 0) {
32
+ items[j + 1] = items[j];
33
+ j -= 1;
34
+ }
35
+ items[j + 1] = item;
36
+ }
37
+ }
38
+
39
+ // Internal: cheap deterministic pivot — median of the first, middle and
40
+ // last elements of the range
41
+ function medianOfThree(items, lo, hi, compare) {
42
+ const first = items[lo];
43
+ const middle = items[lo + ((hi - lo) >> 1)];
44
+ const last = items[hi];
45
+ if (compare(first, middle) < 0) {
46
+ if (compare(middle, last) < 0) return middle;
47
+ return compare(first, last) < 0 ? last : first;
48
+ }
49
+ if (compare(first, last) < 0) return first;
50
+ return compare(middle, last) < 0 ? last : middle;
51
+ }
52
+
53
+ // Internal: median-of-medians pivot for the closed range [lo, hi] — the
54
+ // budget-exhausted fallback. Sorts each group of 5, gathers the group
55
+ // medians at the front of the range, and recursively selects their median;
56
+ // the result splits the range at worst 30/70.
57
+ function medianOfMedians(items, lo, hi, compare) {
58
+ let write = lo;
59
+ for (let i = lo; i <= hi; i += 5) {
60
+ const groupHi = Math.min(i + 4, hi);
61
+ sortRange(items, i, groupHi, compare);
62
+ swap(items, write, i + ((groupHi - i) >> 1));
63
+ write += 1;
64
+ }
65
+ const mid = lo + ((write - 1 - lo) >> 1);
66
+ selectRange(items, mid, compare, lo, write - 1, 6 * (write - lo));
67
+ return items[mid];
68
+ }
69
+
70
+ // Internal: budgeted quickselect on the closed range [lo, hi]. Elements
71
+ // outside the active range are already on their correct side, so when the
72
+ // range narrows to the rank (or the rank falls inside the pivot-equal band)
73
+ // the whole prefix [0..rank] holds the rank+1 smallest.
74
+ function selectRange(items, rank, compare, lo, hi, budget) {
75
+ while (lo < hi) {
76
+ if (hi - lo < 5) {
77
+ sortRange(items, lo, hi, compare);
78
+ return;
79
+ }
80
+ const size = hi - lo + 1;
81
+ const pivot =
82
+ budget > 0
83
+ ? medianOfThree(items, lo, hi, compare)
84
+ : medianOfMedians(items, lo, hi, compare);
85
+ budget -= size;
86
+ // Three-way partition: [lo, lt) < pivot, [lt, gt] == pivot, (gt, hi] >
87
+ // pivot. The equal band makes duplicate-heavy inputs terminate in one
88
+ // round instead of degrading.
89
+ let lt = lo;
90
+ let i = lo;
91
+ let gt = hi;
92
+ while (i <= gt) {
93
+ const order = compare(items[i], pivot);
94
+ if (order < 0) {
95
+ swap(items, lt, i);
96
+ lt += 1;
97
+ i += 1;
98
+ } else if (order > 0) {
99
+ swap(items, i, gt);
100
+ gt -= 1;
101
+ } else {
102
+ i += 1;
103
+ }
104
+ }
105
+ if (rank < lt) {
106
+ hi = lt - 1;
107
+ } else if (rank > gt) {
108
+ lo = gt + 1;
109
+ } else {
110
+ return;
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Partially reorder items IN PLACE so that items[0..rank] are the rank+1
117
+ * smallest under compare and items[rank] is the largest of them (i.e. the
118
+ * rank-th smallest overall, 0-indexed). Order within the two sides is
119
+ * unspecified — exactly what a semi-sorted block structure needs.
120
+ * @param {Array<*>} items - Reordered in place
121
+ * @param {number} rank - 0-indexed target rank, 0 <= rank < items.length
122
+ * @param {(a: *, b: *) => number} [compare] - Comparator (negative when
123
+ * a < b). Defaults to numeric order.
124
+ * @param {number} [cheapBudget] - Elements the median-of-3 phase may visit
125
+ * before pivots switch to median-of-medians. Defaults to 6·items.length
126
+ * (never reached on non-adversarial inputs); pass 0 to force the
127
+ * median-of-medians path throughout (used by tests).
128
+ * @returns {*} items[rank], the rank-th smallest element
129
+ * @throws {Error} If items is not a non-empty array or rank is out of range
130
+ */
131
+ function partitionByRank(items, rank, compare, cheapBudget) {
132
+ if (!Array.isArray(items) || items.length === 0) {
133
+ throw new Error("items must be a non-empty array");
134
+ }
135
+ if (!Number.isInteger(rank) || rank < 0 || rank >= items.length) {
136
+ throw new Error("rank must be an integer index into items");
137
+ }
138
+ const order = compare ?? ((a, b) => (a < b ? -1 : a > b ? 1 : 0));
139
+ const budget = cheapBudget ?? 6 * items.length;
140
+ selectRange(items, rank, order, 0, items.length - 1, budget);
141
+ return items[rank];
142
+ }
143
+
144
+ export { partitionByRank };
package/src/tieBreak.mjs CHANGED
@@ -78,6 +78,26 @@ function compareKeys(a, b) {
78
78
  return 0;
79
79
  }
80
80
 
81
+ /**
82
+ * Lexicographic comparison of an UNPACKED key (length, hops, id) against a
83
+ * packed one — identical order to compareKeys without materializing the
84
+ * left-hand array (#168: the hot relax/routing loops call this instead of
85
+ * allocating a throwaway key per test). Counts as one comparison.
86
+ * @param {number} length - Left key's length component
87
+ * @param {number} hops - Left key's hops component
88
+ * @param {*} id - Left key's id component
89
+ * @param {[number, number, *]} key - Right key, packed
90
+ * @returns {number} Negative when (length, hops, id) < key, positive when
91
+ * greater, 0 when equal
92
+ */
93
+ function compareKeyParts(length, hops, id, key) {
94
+ comparisonCount += 1;
95
+ if (length !== key[0]) return length < key[0] ? -1 : 1;
96
+ if (hops !== key[1]) return hops < key[1] ? -1 : 1;
97
+ if (id !== key[2]) return id < key[2] ? -1 : 1;
98
+ return 0;
99
+ }
100
+
81
101
  /**
82
102
  * Normalize a bound to a composite key. A scalar B becomes the infimum of
83
103
  * all keys of length B, preserving the strict "distance < B" contract;
@@ -130,6 +150,11 @@ function orderKey(v, dHat, ties) {
130
150
  * tied alternative parents; callers filter already-completed vertices to
131
151
  * keep the re-enqueue finite, exactly like the paper.
132
152
  *
153
+ * Allocation-free since #168: the result is one of the three RELAX_* codes,
154
+ * and a caller that needs v's (possibly updated) order key materializes it
155
+ * with orderKey(v, dHat, ties) — only on the rare paths that enqueue v,
156
+ * instead of on every attempt.
157
+ *
133
158
  * @param {*} u - Edge tail (its labels are read)
134
159
  * @param {*} v - Edge head (its labels may be updated)
135
160
  * @param {number} weight - Edge weight, >= 0
@@ -137,38 +162,59 @@ function orderKey(v, dHat, ties) {
137
162
  * @param {{ hops: Map<*, number>, preds: Map<*, *> }} ties - Updated in place
138
163
  * @param {[number, number, *]} [bound] - Optional gate: skip (no d̂ update)
139
164
  * 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)
165
+ * @returns {number} RELAX_IMPROVED when the labels were updated,
166
+ * RELAX_EQUAL when the candidate exactly matches v's canonical label
167
+ * (the re-enqueue signal), RELAX_LOST when the candidate loses or the
168
+ * bound gates it (labels untouched)
144
169
  */
145
170
  function relaxEdge(u, v, weight, dHat, ties, bound) {
146
171
  const length = dHat.get(u) + weight;
147
172
  const hopCount = (ties.hops.get(u) ?? 0) + 1;
148
- if (bound !== undefined && compareKeys([length, hopCount, v], bound) >= 0) {
149
- return null;
173
+ if (bound !== undefined && compareKeyParts(length, hopCount, v, bound) >= 0) {
174
+ return RELAX_LOST;
150
175
  }
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 };
176
+ // Candidate path key [length, hopCount, u] vs. v's current path key
177
+ // [d̂[v], hops[v], preds[v] ?? NO_PRED] — compareKeys inlined on the
178
+ // unpacked components (one counted comparison, no arrays)
179
+ comparisonCount += 1;
180
+ let cmp;
181
+ const currentLength = dHat.get(v) ?? Infinity;
182
+ if (length !== currentLength) {
183
+ cmp = length < currentLength ? -1 : 1;
184
+ } else {
185
+ const currentHops = ties.hops.get(v) ?? 0;
186
+ if (hopCount !== currentHops) {
187
+ cmp = hopCount < currentHops ? -1 : 1;
188
+ } else {
189
+ const currentPred = ties.preds.has(v) ? ties.preds.get(v) : NO_PRED;
190
+ cmp = u !== currentPred ? (u < currentPred ? -1 : 1) : 0;
191
+ }
192
+ }
193
+ if (cmp > 0) return RELAX_LOST;
194
+ if (cmp === 0) return RELAX_EQUAL;
159
195
  dHat.set(v, length);
160
196
  ties.hops.set(v, hopCount);
161
197
  ties.preds.set(v, u);
162
- return { key: [length, hopCount, v], improved: true };
198
+ return RELAX_IMPROVED;
163
199
  }
164
200
 
201
+ // relaxEdge result codes (#168): distinct, and only RELAX_LOST is falsy-like
202
+ // in comparisons — callers must compare against the constants, not truthiness
203
+ const RELAX_LOST = -1;
204
+ const RELAX_EQUAL = 0;
205
+ const RELAX_IMPROVED = 1;
206
+
165
207
  export {
166
208
  compareKeys,
209
+ compareKeyParts,
167
210
  toBound,
168
211
  makeTies,
169
212
  orderKey,
170
213
  relaxEdge,
171
214
  NO_PRED,
215
+ RELAX_LOST,
216
+ RELAX_EQUAL,
217
+ RELAX_IMPROVED,
172
218
  resetComparisonCount,
173
219
  getComparisonCount,
174
220
  };