bmssp 1.1.0 → 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
@@ -34,15 +34,30 @@ with every building block shipped, tested, and released individually:
34
34
  | Shortest-path reconstruction ([#169](https://github.com/Sirivasv/bmssp-js/issues/169)) | `BMSSP.reconstructPath()` | ✅ done |
35
35
  | Constructor input validation ([#165](https://github.com/Sirivasv/bmssp-js/issues/165)) | `BMSSP` constructor | ✅ done |
36
36
  | Opt-in constant-degree transform, in/out-degree ≤ 2 ([#164](https://github.com/Sirivasv/bmssp-js/issues/164)) | `constantDegreeTransform()` | ✅ done |
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
+ | 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** |
37
41
 
38
42
  > **Honest note:** the paper's win is asymptotic, and this repo optimizes for correctness
39
43
  > and readability, not raw speed. Measured head-to-head (algorithm time only, graph
40
- > loading excluded — see [benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md)):
44
+ > loading excluded — rerun it yourself with `npm run bench`; methodology and the deep
45
+ > 1.0.0 record in [benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md)):
41
46
  > Dijkstra still wins on wall-clock at every practical size, though the gap narrows as
42
47
  > sparse graphs grow (2.5× at 50k nodes → 1.57× at 2M). But in the paper's own metric —
43
48
  > **comparisons between path lengths** — BMSSP does **fewer comparisons than Dijkstra
44
- > from about n = 1M on sparse graphs** (0.91× at n = 2M), and the advantage grows with
45
- > size: the "sorting barrier" is measurably broken; what remains is JS constant factors.
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
56
+ > run down in [#182](https://github.com/Sirivasv/bmssp-js/issues/182): the star-graph
57
+ > blowup was a quadratic in `BatchPrepend`'s bookkeeping — fixed in `1.1.1` (500k-node
58
+ > star: 61 s → ~3 s) — and the recursion-level step is an inherent, measured ~+24% per
59
+ > extra level (see the addendum in
60
+ > [benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md)).
46
61
  > Where inputs violate the paper's distinct-path-lengths assumption (e.g. zero-weight
47
62
  > edges), a principled deterministic tie-break
48
63
  > ([#163](https://github.com/Sirivasv/bmssp-js/issues/163)) realizes that assumption in
@@ -60,8 +75,9 @@ with every building block shipped, tested, and released individually:
60
75
  closest batch without paying the Θ(log n)-per-vertex "sorting barrier."
61
76
 
62
77
  The wall-clock crossover point is astronomically large, but the asymptotics are real: in
63
- measured comparison counts this implementation already beats Dijkstra past ~1M nodes on
64
- 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
65
81
  optimizes for a **correct, readable, well-tested** implementation, validated line-by-line
66
82
  against a Dijkstra oracle — not for raw speed.
67
83
 
@@ -145,14 +161,17 @@ Other image versions are on [Docker Hub](https://hub.docker.com/r/sirivasv/bmssp
145
161
 
146
162
  ```bash
147
163
  npm install
148
- npm test # Jest suite — every graph seeded, every failure reproducible (~3 s)
149
- npm run lint # Prettier + ESLint
150
- npm run bench # seeded micro-benchmarks (see benchmarks/README.md)
164
+ npm test # Jest suite — every graph seeded, every failure reproducible (~7 s)
165
+ npm run lint # Prettier + ESLint
166
+ npm run bench # BMSSP-vs-Dijkstra head-to-head per graph shape, outputs verified
167
+ npm run bench:counts # …plus comparison-count tables (the paper's own cost metric)
151
168
  ```
152
169
 
153
- The measured BMSSP-vs-Dijkstra head-to-head wall-clock and comparison counts, with
154
- methodology lives in [benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md)
155
- ([#170](https://github.com/Sirivasv/bmssp-js/issues/170) tracks harness integration).
170
+ The benchmark harness runs the measured BMSSP-vs-Dijkstra head-to-head on every
171
+ `npm run bench` ([#170](https://github.com/Sirivasv/bmssp-js/issues/170)); methodology,
172
+ the deep 1.0.0 record (up to n = 4M) and the "when to use which" guidance live in
173
+ [benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md) and
174
+ [benchmarks/README.md](benchmarks/README.md).
156
175
 
157
176
  The test suite's core contract: for every node, BMSSP's distances must equal the Dijkstra
158
177
  oracle's. A seeded property/fuzz suite (`test/fuzz.test.mjs`) hammers that contract across
@@ -170,8 +189,8 @@ graphs in a few seconds), and set `FUZZ_XL=1` for an additional 2-million-node r
170
189
  | --- | --- | --- |
171
190
  | [`1.0.0`](https://github.com/Sirivasv/bmssp-js/milestones) | First end-to-end functional BMSSP (issues #40–#45) | ✅ done |
172
191
  | `1.1.0` | Correctness hardening — fuzz tests, edge cases, tie-breaking, input validation, constant-degree transform, API docs | ✅ done |
173
- | `1.2.0` | Performance & ergonomics — exact Lemma 3.3 asymptotics, BMSSP-vs-Dijkstra benchmarks, performance-cliff investigation | 🔨 current focus |
174
- | `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 |
175
194
 
176
195
  ## Contributing (humans and AI agents welcome)
177
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmssp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Javascript package implementation of the bmssp algorithm.",
5
5
  "main": "index.mjs",
6
6
  "keywords": [
@@ -35,6 +35,7 @@
35
35
  "scripts": {
36
36
  "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest --coverage",
37
37
  "bench": "node benchmarks/run.mjs",
38
+ "bench:counts": "node benchmarks/run.mjs --counts",
38
39
  "lint": "npm run prettier && npm run eslint",
39
40
  "format": "npm run prettier:fix && npm run eslint:fix",
40
41
  "eslint": "eslint --max-warnings=0 .",
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,21 +154,36 @@ 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
- // Prepend in reverse chunk order so the smallest chunk lands at the front
160
- for (let c = chunks.length - 1; c >= 0; c -= 1) {
161
- const chunk = chunks[c];
184
+ // Materialize the chunks as blocks (ascending order)...
185
+ const blocks = [];
186
+ for (const chunk of chunks) {
162
187
  // Seed the block bound with the first value, then max-update — avoids
163
188
  // needing a -Infinity sentinel that a custom comparator can't order
164
189
  const block = this.makeBlock(chunk[0][1]);
@@ -168,8 +193,30 @@ class BlockList {
168
193
  if (this.compare(value, block.bound) > 0) block.bound = value;
169
194
  this.count += 1;
170
195
  }
171
- this.d0.unshift(block);
196
+ blocks.push(block);
197
+ }
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;
172
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);
173
220
  }
174
221
 
175
222
  /**
@@ -184,8 +231,10 @@ class BlockList {
184
231
  if (this.count <= this.M) {
185
232
  // Everything fits in one batch: drain the structure and reset it
186
233
  const keys = new Set(this.locator.keys());
187
- this.d0 = [];
188
- 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);
189
238
  this.locator.clear();
190
239
  this.count = 0;
191
240
  return { keys, bound: this.B };
@@ -193,18 +242,31 @@ class BlockList {
193
242
  // Collect prefix blocks from each sequence until that side holds >= M
194
243
  // candidate elements (or runs out). The M smallest overall are in there.
195
244
  const candidates = [];
196
- for (const seq of [this.d0, this.d1]) {
197
- let collected = 0;
198
- for (const block of seq) {
199
- if (collected >= this.M) break;
200
- for (const [key, value] of block.entries) {
201
- candidates.push([key, value, block]);
202
- }
203
- 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]);
204
253
  }
254
+ collected += block.entries.size;
205
255
  }
206
- // Take the M smallest candidates out of the structure
207
- 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);
208
270
  const keys = new Set();
209
271
  for (let i = 0; i < this.M; i += 1) {
210
272
  const [key, , block] = candidates[i];
@@ -217,12 +279,20 @@ class BlockList {
217
279
  // Under a strict total order (#163's composite keys) this separator is
218
280
  // strictly above every pulled value — no boundary ties.
219
281
  let bound = null;
220
- for (const seq of [this.d0, this.d1]) {
221
- const block = seq.find((b) => b.entries.size > 0);
222
- if (block) {
282
+ for (let block = this.d0Head; block !== null; block = block.next) {
283
+ if (block.entries.size > 0) {
223
284
  for (const value of block.entries.values()) {
224
285
  if (bound === null || this.compare(value, bound) < 0) bound = value;
225
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;
226
296
  }
227
297
  }
228
298
  return { keys, bound };
@@ -239,18 +309,18 @@ class BlockList {
239
309
  }
240
310
  }
241
311
 
242
- // Internal: physically remove an emptied block. The last d1 block (bound B)
243
- // 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.
244
315
  dropIfEmpty(block) {
245
- const i0 = this.d0.indexOf(block);
246
- if (i0 !== -1) {
247
- this.d0.splice(i0, 1);
316
+ if (block.node !== null) {
317
+ if (block === this.lastD1Block) return;
318
+ this.d1.remove(block.node);
248
319
  return;
249
320
  }
250
- const i1 = this.d1.indexOf(block);
251
- if (i1 !== -1 && i1 < this.d1.length - 1) {
252
- this.d1.splice(i1, 1);
253
- }
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;
254
324
  }
255
325
  }
256
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
@@ -42,6 +42,28 @@
42
42
  // source's own label never loses an equal-(length, hops) comparison.
43
43
  const NO_PRED = -Infinity;
44
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
+
45
67
  /**
46
68
  * Lexicographic comparison of two composite keys.
47
69
  * @param {[number, number, *]} a - [length, hops, id]
@@ -49,12 +71,33 @@ const NO_PRED = -Infinity;
49
71
  * @returns {number} Negative when a < b, positive when a > b, 0 when equal
50
72
  */
51
73
  function compareKeys(a, b) {
74
+ comparisonCount += 1;
52
75
  if (a[0] !== b[0]) return a[0] < b[0] ? -1 : 1;
53
76
  if (a[1] !== b[1]) return a[1] < b[1] ? -1 : 1;
54
77
  if (a[2] !== b[2]) return a[2] < b[2] ? -1 : 1;
55
78
  return 0;
56
79
  }
57
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
+
58
101
  /**
59
102
  * Normalize a bound to a composite key. A scalar B becomes the infimum of
60
103
  * all keys of length B, preserving the strict "distance < B" contract;
@@ -107,6 +150,11 @@ function orderKey(v, dHat, ties) {
107
150
  * tied alternative parents; callers filter already-completed vertices to
108
151
  * keep the re-enqueue finite, exactly like the paper.
109
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
+ *
110
158
  * @param {*} u - Edge tail (its labels are read)
111
159
  * @param {*} v - Edge head (its labels may be updated)
112
160
  * @param {number} weight - Edge weight, >= 0
@@ -114,29 +162,59 @@ function orderKey(v, dHat, ties) {
114
162
  * @param {{ hops: Map<*, number>, preds: Map<*, *> }} ties - Updated in place
115
163
  * @param {[number, number, *]} [bound] - Optional gate: skip (no d̂ update)
116
164
  * 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)
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)
121
169
  */
122
170
  function relaxEdge(u, v, weight, dHat, ties, bound) {
123
171
  const length = dHat.get(u) + weight;
124
172
  const hopCount = (ties.hops.get(u) ?? 0) + 1;
125
- if (bound !== undefined && compareKeys([length, hopCount, v], bound) >= 0) {
126
- return null;
173
+ if (bound !== undefined && compareKeyParts(length, hopCount, v, bound) >= 0) {
174
+ return RELAX_LOST;
175
+ }
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
+ }
127
192
  }
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 };
193
+ if (cmp > 0) return RELAX_LOST;
194
+ if (cmp === 0) return RELAX_EQUAL;
136
195
  dHat.set(v, length);
137
196
  ties.hops.set(v, hopCount);
138
197
  ties.preds.set(v, u);
139
- return { key: [length, hopCount, v], improved: true };
198
+ return RELAX_IMPROVED;
140
199
  }
141
200
 
142
- export { compareKeys, toBound, makeTies, orderKey, relaxEdge, NO_PRED };
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
+
207
+ export {
208
+ compareKeys,
209
+ compareKeyParts,
210
+ toBound,
211
+ makeTies,
212
+ orderKey,
213
+ relaxEdge,
214
+ NO_PRED,
215
+ RELAX_LOST,
216
+ RELAX_EQUAL,
217
+ RELAX_IMPROVED,
218
+ resetComparisonCount,
219
+ getComparisonCount,
220
+ };