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.
package/src/blockList.mjs CHANGED
@@ -22,15 +22,19 @@ class BlockList {
22
22
  /**
23
23
  * Initialize the structure (Lemma 3.3 Initialize).
24
24
  * @param {number} M - Block size / pull batch size, >= 1. At recursion level l this is 2^((l-1)·t).
25
- * @param {number} B - Strict upper bound on every value ever stored (Infinity is allowed)
25
+ * @param {*} B - Strict upper bound on every value ever stored (Infinity is allowed)
26
+ * @param {(a: *, b: *) => number} [compare] - Value comparator (negative
27
+ * when a < b). Defaults to numeric order; #163 passes composite
28
+ * [length, hops, id] keys with their lexicographic comparator.
26
29
  * @throws {Error} If M is not a number >= 1
27
30
  */
28
- constructor(M, B) {
31
+ constructor(M, B, compare) {
29
32
  if (typeof M !== "number" || Number.isNaN(M) || M < 1) {
30
33
  throw new Error("M must be a number >= 1");
31
34
  }
32
35
  this.M = Math.floor(M);
33
36
  this.B = B;
37
+ this.compare = compare ?? ((a, b) => (a < b ? -1 : a > b ? 1 : 0));
34
38
  // d1 starts as a single empty block with upper bound B
35
39
  this.d1 = [this.makeBlock(B)];
36
40
  this.d0 = [];
@@ -61,12 +65,12 @@ class BlockList {
61
65
  * @throws {Error} If value >= B
62
66
  */
63
67
  insert(key, value) {
64
- if (!(value < this.B)) {
68
+ if (!(this.compare(value, this.B) < 0)) {
65
69
  throw new Error("value must be < B");
66
70
  }
67
71
  const holder = this.locator.get(key);
68
72
  if (holder !== undefined) {
69
- if (holder.entries.get(key) <= value) return;
73
+ if (this.compare(holder.entries.get(key), value) <= 0) return;
70
74
  this.removeKey(key, holder);
71
75
  }
72
76
  // Binary-search d1 for the first block whose bound covers the value
@@ -74,7 +78,7 @@ class BlockList {
74
78
  let hi = this.d1.length - 1;
75
79
  while (lo < hi) {
76
80
  const mid = (lo + hi) >> 1;
77
- if (this.d1[mid].bound >= value) {
81
+ if (this.compare(this.d1[mid].bound, value) >= 0) {
78
82
  hi = mid;
79
83
  } else {
80
84
  lo = mid + 1;
@@ -96,7 +100,7 @@ class BlockList {
96
100
  // simpler — acceptable for this correctness-first implementation.)
97
101
  splitBlock(index) {
98
102
  const block = this.d1[index];
99
- const sorted = [...block.entries].sort((a, b) => a[1] - b[1]);
103
+ const sorted = [...block.entries].sort((a, b) => this.compare(a[1], b[1]));
100
104
  const half = sorted.length >> 1;
101
105
  const lower = this.makeBlock(sorted[half - 1][1]);
102
106
  for (let i = 0; i < half; i += 1) {
@@ -121,11 +125,11 @@ class BlockList {
121
125
  // Dedupe the batch, keeping the smallest value per key
122
126
  const best = new Map();
123
127
  for (const [key, value] of pairs) {
124
- if (!(value < this.B)) {
128
+ if (!(this.compare(value, this.B) < 0)) {
125
129
  throw new Error("value must be < B");
126
130
  }
127
131
  const seen = best.get(key);
128
- if (seen === undefined || value < seen) {
132
+ if (seen === undefined || this.compare(value, seen) < 0) {
129
133
  best.set(key, value);
130
134
  }
131
135
  }
@@ -134,7 +138,7 @@ class BlockList {
134
138
  for (const [key, value] of best) {
135
139
  const holder = this.locator.get(key);
136
140
  if (holder !== undefined) {
137
- if (holder.entries.get(key) <= value) continue;
141
+ if (this.compare(holder.entries.get(key), value) <= 0) continue;
138
142
  this.removeKey(key, holder);
139
143
  }
140
144
  fresh.push([key, value]);
@@ -145,7 +149,7 @@ class BlockList {
145
149
  if (fresh.length <= this.M) {
146
150
  chunks = [fresh];
147
151
  } else {
148
- fresh.sort((a, b) => a[1] - b[1]);
152
+ fresh.sort((a, b) => this.compare(a[1], b[1]));
149
153
  const chunkSize = Math.ceil(this.M / 2);
150
154
  chunks = [];
151
155
  for (let i = 0; i < fresh.length; i += chunkSize) {
@@ -155,11 +159,13 @@ class BlockList {
155
159
  // Prepend in reverse chunk order so the smallest chunk lands at the front
156
160
  for (let c = chunks.length - 1; c >= 0; c -= 1) {
157
161
  const chunk = chunks[c];
158
- const block = this.makeBlock(-Infinity);
162
+ // Seed the block bound with the first value, then max-update — avoids
163
+ // needing a -Infinity sentinel that a custom comparator can't order
164
+ const block = this.makeBlock(chunk[0][1]);
159
165
  for (const [key, value] of chunk) {
160
166
  block.entries.set(key, value);
161
167
  this.locator.set(key, block);
162
- if (value > block.bound) block.bound = value;
168
+ if (this.compare(value, block.bound) > 0) block.bound = value;
163
169
  this.count += 1;
164
170
  }
165
171
  this.d0.unshift(block);
@@ -198,21 +204,24 @@ class BlockList {
198
204
  }
199
205
  }
200
206
  // Take the M smallest candidates out of the structure
201
- candidates.sort((a, b) => a[1] - b[1]);
207
+ candidates.sort((a, b) => this.compare(a[1], b[1]));
202
208
  const keys = new Set();
203
209
  for (let i = 0; i < this.M; i += 1) {
204
210
  const [key, , block] = candidates[i];
205
211
  keys.add(key);
206
212
  this.removeKey(key, block);
207
213
  }
208
- // Separator = smallest value still stored. Thanks to the inter-block
209
- // ordering it lives in the first non-empty block of d0 or d1.
210
- let bound = Infinity;
214
+ // Separator = smallest value still stored (non-null here: this branch
215
+ // only runs when more than M values were present). Thanks to the
216
+ // inter-block ordering it lives in the first non-empty block of d0/d1.
217
+ // Under a strict total order (#163's composite keys) this separator is
218
+ // strictly above every pulled value — no boundary ties.
219
+ let bound = null;
211
220
  for (const seq of [this.d0, this.d1]) {
212
221
  const block = seq.find((b) => b.entries.size > 0);
213
222
  if (block) {
214
223
  for (const value of block.entries.values()) {
215
- if (value < bound) bound = value;
224
+ if (bound === null || this.compare(value, bound) < 0) bound = value;
216
225
  }
217
226
  }
218
227
  }
package/src/bmssp.mjs CHANGED
@@ -1,9 +1,20 @@
1
1
  import { baseCase } from "./baseCase.mjs";
2
2
  import { findPivots } from "./findPivots.mjs";
3
3
  import { BlockList } from "./blockList.mjs";
4
+ import {
5
+ compareKeys,
6
+ toBound,
7
+ makeTies,
8
+ orderKey,
9
+ relaxEdge,
10
+ } from "./tieBreak.mjs";
4
11
 
5
12
  class BMSSP {
6
13
  constructor(inputGraph) {
14
+ if (!Array.isArray(inputGraph)) {
15
+ throw new Error("Input graph must be an array of edges");
16
+ }
17
+
7
18
  // Main graph represented as an array of edges
8
19
  this.graph = [];
9
20
  // Set to store unique node IDs
@@ -14,8 +25,29 @@ class BMSSP {
14
25
  // Lets the algorithm fetch a node's edges in O(1) instead of scanning
15
26
  // the whole edge list on every lookup.
16
27
  this.adjacency = new Map();
28
+ // Canonical tie-break labels (#163): hops = edge count of the canonical
29
+ // shortest path, preds = its predecessor pointer. Updated in lockstep
30
+ // with shortestPaths by relaxEdge; together they realize the paper's
31
+ // Assumption 2.1 (distinct path lengths) via [length, hops, id] keys.
32
+ this.hops = new Map();
33
+ this.preds = new Map();
34
+ this.ties = makeTies(this.hops, this.preds);
35
+
36
+ for (let [index, edge] of inputGraph.entries()) {
37
+ if (!Array.isArray(edge) || edge.length !== 3) {
38
+ throw new Error(`Edge at index ${index} must be [from, to, weight]`);
39
+ }
40
+
41
+ const [from, to, weight] = edge;
42
+ if (!Number.isFinite(from) || !Number.isFinite(to)) {
43
+ throw new Error(`Edge at index ${index} must have numeric node IDs`);
44
+ }
45
+ if (!Number.isFinite(weight) || weight < 0) {
46
+ throw new Error(
47
+ `Edge at index ${index} must have a non-negative numeric weight`,
48
+ );
49
+ }
17
50
 
18
- for (let edge of inputGraph) {
19
51
  // Create a deep copy of each edge array
20
52
  this.graph.push([...edge]);
21
53
 
@@ -57,11 +89,14 @@ class BMSSP {
57
89
  return this.adjacency.get(nodeId) ?? [];
58
90
  }
59
91
 
60
- // Method to initialize the shortest paths map
92
+ // Method to initialize the shortest paths map (and the #163 tie-break
93
+ // labels that travel with it)
61
94
  initializeShortestPaths() {
62
95
  for (let nodeId of this.nodeIDs) {
63
96
  this.shortestPaths.set(nodeId, Infinity);
64
97
  }
98
+ this.hops.clear();
99
+ this.preds.clear();
65
100
  }
66
101
 
67
102
  /**
@@ -93,45 +128,79 @@ class BMSSP {
93
128
  * path through some complete vertex of S.
94
129
  *
95
130
  * Distance estimates live in this.shortestPaths (the paper's d̂[·]) and
96
- * are relaxed in place at every level.
131
+ * are relaxed in place at every level, together with the canonical hops
132
+ * and preds labels (#163). All internal ordering uses the composite
133
+ * [length, hops, id] keys of src/tieBreak.mjs, which realize the paper's
134
+ * Assumption 2.1: pull separators are strict, no key ever ties a bound,
135
+ * and the pre-#163 degenerate-tie guards (out-of-scope pivots,
136
+ * boundary-tied batch members, the empty-child stall escape hatch) are
137
+ * unnecessary by construction.
97
138
  *
98
- * Two outcomes (Lemma 3.1):
99
- * - Successful execution: the block list emptied — bound === B and
100
- * vertices holds every v with d(v) < B reachable through S.
101
- * - Partial execution: the k·2^(l·t) workload guard tripped — bound < B
102
- * and vertices holds exactly the v with d(v) < bound.
139
+ * Two outcomes (Lemma 3.1, strict under the composite order):
140
+ * - Successful execution: the block list emptied — boundKey === B's key
141
+ * and vertices holds every v with key(v) < B reachable through S.
142
+ * - Partial execution: the k·2^(l·t) workload guard tripped —
143
+ * boundKey < B's key and vertices holds exactly the v with
144
+ * key(v) < boundKey. In the scalar projection a returned vertex may tie
145
+ * the returned bound's length (never exceed it).
103
146
  * Every returned vertex is complete either way.
104
147
  *
105
148
  * @param {number} l - Recursion level; 0 delegates to baseCase
106
- * @param {number} B - Strict upper bound on the distances in scope (Infinity is allowed)
149
+ * @param {number|[number, number, *]} B - Strict upper bound on the keys
150
+ * in scope: a number (Infinity is allowed) or a composite bound
107
151
  * @param {Set<*>} S - Non-empty set of complete frontier sources
108
- * @returns {{ bound: number, vertices: Set<*> }} The boundary B' <= B and
109
- * the set U of vertices completed below it
152
+ * @returns {{ bound: number|[number, number, *], boundKey: [number, number, *], vertices: Set<*> }}
153
+ * The boundary B' <= B (same kind as the B passed in: scalar callers
154
+ * get a scalar), its composite key, and the set U of vertices
155
+ * completed below it
110
156
  */
111
157
  bmssp(l, B, S) {
112
158
  const dHat = this.shortestPaths;
159
+ const ties = this.ties;
160
+ const boundKey = toBound(B);
161
+ const scalarB = typeof B === "number";
162
+ // Project the composite result back to the caller's kind: a successful
163
+ // execution echoes B itself, a partial one reports the separator (whose
164
+ // length is strictly below a scalar B by construction)
165
+ const finish = (finalKey, vertices) => ({
166
+ bound: scalarB
167
+ ? compareKeys(finalKey, boundKey) === 0
168
+ ? B
169
+ : finalKey[0]
170
+ : finalKey,
171
+ boundKey: finalKey,
172
+ vertices,
173
+ });
174
+
113
175
  if (l === 0) {
114
- return baseCase(B, S, dHat, this.adjacency, this.k);
176
+ const result = baseCase(boundKey, S, dHat, this.adjacency, this.k, ties);
177
+ return finish(result.boundKey, result.vertices);
115
178
  }
116
179
 
117
180
  // Shrink the frontier: only the pivots are worth recursing on, and W
118
181
  // is a batch of already-completed vertices folded in at the end
119
- const { pivots, W } = findPivots(B, S, dHat, this.adjacency, this.k);
120
-
121
- // Seed the Lemma 3.3 block list with the pivots. lastBound tracks the
122
- // paper's Bi': B when P is empty, min d̂ over P before the first pull,
123
- // then the boundary returned by the latest recursive call.
124
- // A pivot with d̂ >= B is out of scope at this level (only possible
125
- // when equal path lengths violate Assumption 2.1 and a pull returned
126
- // a key tied with its separator): skip it — the ancestor whose band
127
- // covers its distance is responsible for it.
128
- const D = new BlockList(2 ** ((l - 1) * this.t), B);
129
- let lastBound = B;
182
+ const { pivots, W } = findPivots(
183
+ boundKey,
184
+ S,
185
+ dHat,
186
+ this.adjacency,
187
+ this.k,
188
+ ties,
189
+ );
190
+
191
+ // Seed the Lemma 3.3 block list with the pivots. lastBoundKey tracks
192
+ // the paper's Bi': B when P is empty, min key over P before the first
193
+ // pull, then the boundary returned by the latest recursive call.
194
+ // The scope filter is for direct multi-source callers who may pass
195
+ // sources at or above B — internal calls can't produce one, because
196
+ // every pull separator is strict under the composite order.
197
+ const D = new BlockList(2 ** ((l - 1) * this.t), boundKey, compareKeys);
198
+ let lastBoundKey = boundKey;
130
199
  for (const x of pivots) {
131
- const dx = dHat.get(x);
132
- if (dx < B) {
133
- D.insert(x, dx);
134
- if (dx < lastBound) lastBound = dx;
200
+ const key = orderKey(x, dHat, ties);
201
+ if (compareKeys(key, boundKey) < 0) {
202
+ D.insert(x, key);
203
+ if (compareKeys(key, lastBoundKey) < 0) lastBoundKey = key;
135
204
  }
136
205
  }
137
206
 
@@ -140,86 +209,64 @@ class BMSSP {
140
209
 
141
210
  while (U.size < workloadCap && !D.isEmpty()) {
142
211
  // Bi, Si <- D.Pull(): the next-closest small batch and its separator
143
- const { keys: Si, bound: Bi } = D.pull();
144
- let { bound: BiPrime, vertices: Ui } = this.bmssp(l - 1, Bi, Si);
145
-
146
- if (Ui.size === 0) {
147
- // Degenerate tie stall: the child settled only vertices tied
148
- // exactly at its boundary (possible only through zero-weight
149
- // paths, which violate the paper's Assumption 2.1 — see #163),
150
- // so its strict d̂ < B' filter returned nothing and this batch
151
- // would be re-pulled forever. Escape hatch: settle everything
152
- // below Bi reachable through the batch with an uncapped bounded
153
- // Dijkstra per member — correct, just not sublinear.
154
- Ui = new Set();
155
- for (const x of Si) {
156
- const { vertices } = baseCase(
157
- Bi,
158
- new Set([x]),
159
- dHat,
160
- this.adjacency,
161
- Math.max(1, this.nodeIDs.size),
162
- );
163
- for (const v of vertices) Ui.add(v);
164
- }
165
- BiPrime = Bi;
166
- }
212
+ const { keys: Si, bound: BiKey } = D.pull();
213
+ const child = this.bmssp(l - 1, BiKey, Si);
214
+ const BiPrimeKey = child.boundKey;
215
+ const Ui = child.vertices;
167
216
 
168
- lastBound = BiPrime;
217
+ lastBoundKey = BiPrimeKey;
169
218
  for (const v of Ui) U.add(v);
170
219
 
171
- // Relax out of the newly-completed Ui, routing improved neighbors
172
- // by distance band: [Bi, B) re-enters the block list, [Bi', Bi) is
173
- // staged for a batch prepend (closer than the current batch's floor)
220
+ // Relax out of the newly-completed Ui, routing improved neighbors by
221
+ // key band: [Bi, B) re-enters the block list, [Bi', Bi) is staged for
222
+ // a batch prepend (closer than the current batch's floor). An exact
223
+ // canonical equality re-enqueues a vertex that a deeper call labeled
224
+ // without completing (the paper's `≤` relaxation, deterministic here:
225
+ // only the recorded label-setter triggers it); vertices already
226
+ // completed at this level are filtered so re-enqueues stay finite.
174
227
  const K = [];
175
228
  for (const u of Ui) {
176
- const du = dHat.get(u);
177
229
  for (const [v, weight] of this.adjacency.get(u) ?? []) {
178
- const candidate = du + weight;
179
- // Paper relaxation: d̂[u] + w(u,v) <= d̂[v] always updates d̂
180
- if (candidate <= (dHat.get(v) ?? Infinity)) {
181
- dHat.set(v, candidate);
182
- // A vertex already completed at this level cannot strictly
183
- // improve (non-negative weights), so an equal-sum relaxation
184
- // — which the <= allows, e.g. via a zero-weight cycle — must
185
- // not be re-queued (mirrors the baseCase settled guard)
186
- if (U.has(v)) continue;
187
- if (candidate >= Bi && candidate < B) {
188
- D.insert(v, candidate);
189
- } else if (candidate >= BiPrime && candidate < Bi) {
190
- K.push([v, candidate]);
191
- }
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]);
192
240
  }
193
241
  }
194
242
  }
195
- // Batch members the child did not complete ( still in [Bi', Bi))
196
- // go back in front of everything else. A member tied exactly at the
197
- // separator (d̂ == Bi < B, an Assumption 2.1 violation) is still in
198
- // scope at this level and re-enters through a regular insert.
243
+ // Batch members the child did not complete (key still in [Bi', Bi))
244
+ // go back in front of everything else
199
245
  for (const x of Si) {
200
246
  if (U.has(x)) continue;
201
- const dx = dHat.get(x);
202
- if (dx >= BiPrime && dx < Bi) {
203
- K.push([x, dx]);
204
- } else if (dx === Bi && Bi < B) {
205
- D.insert(x, dx);
247
+ const key = orderKey(x, dHat, ties);
248
+ if (compareKeys(key, BiPrimeKey) >= 0 && compareKeys(key, BiKey) < 0) {
249
+ K.push([x, key]);
206
250
  }
207
251
  }
208
252
  if (K.length > 0) D.batchPrepend(K);
209
253
  }
210
254
 
211
255
  // B' <- min(last Bi', B); fold in the FindPivots batch below it
212
- const bound = Math.min(lastBound, B);
256
+ const finalKey =
257
+ compareKeys(lastBoundKey, boundKey) < 0 ? lastBoundKey : boundKey;
213
258
  for (const x of W) {
214
- if (dHat.get(x) < bound) U.add(x);
259
+ if (compareKeys(orderKey(x, dHat, ties), finalKey) < 0) U.add(x);
215
260
  }
216
- return { bound, vertices: U };
261
+ return finish(finalKey, U);
217
262
  }
218
263
 
219
264
  // Method to calculate shortest paths from startNode via the BMSSP
220
265
  // recursion (Algorithm 3). The top-level call BMSSP(topLevel, ∞, {start})
221
266
  // is always a successful execution, so it completes every reachable
222
- // vertex; unreachable ones keep their Infinity estimate.
267
+ // vertex; unreachable ones keep their Infinity estimate. Distances, hop
268
+ // counts and predecessor pointers all end at their canonical values —
269
+ // independent of edge or iteration order (#163).
223
270
  calculateShortestPaths(startNode) {
224
271
  // To clean the state before calculation
225
272
  this.initializeShortestPaths();
@@ -229,10 +276,36 @@ class BMSSP {
229
276
  throw new Error("Start node not found in the graph");
230
277
  }
231
278
 
232
- // The source is complete at distance 0; everything else is Infinity
279
+ // The source is complete at distance 0 with zero hops and no
280
+ // predecessor; everything else is Infinity
233
281
  this.shortestPaths.set(startNode, 0);
282
+ this.hops.set(startNode, 0);
234
283
  this.bmssp(this.topLevel, Infinity, new Set([startNode]));
235
284
  }
285
+
286
+ /**
287
+ * Reconstruct the canonical shortest path from the most recent source to
288
+ * target. Returns an empty array when target is unreachable or no shortest
289
+ * path run has completed yet.
290
+ *
291
+ * @param {number} target - A node in the graph
292
+ * @returns {number[]} Node IDs from the source through target
293
+ * @throws {Error} If target is not in the graph
294
+ */
295
+ reconstructPath(target) {
296
+ if (!this.nodeIDs.has(target)) {
297
+ throw new Error("Target node not found in the graph");
298
+ }
299
+ if (this.shortestPaths.get(target) === Infinity) return [];
300
+
301
+ const path = [target];
302
+ let current = target;
303
+ while (this.preds.has(current)) {
304
+ current = this.preds.get(current);
305
+ path.push(current);
306
+ }
307
+ return path.reverse();
308
+ }
236
309
  }
237
310
 
238
311
  export { BMSSP };
@@ -0,0 +1,136 @@
1
+ // Constant-degree transform — the paper's Preliminaries (knowledge/01 §
2
+ // "Preliminaries / model assumptions"; term in knowledge/07). The
3
+ // O(m·log^(2/3) n) bound is argued on a graph whose vertices all have
4
+ // in-degree AND out-degree <= 2. This module rewrites any graph into that
5
+ // shape while preserving every distance.
6
+ //
7
+ // OPT-IN, by design. Nothing in the core algorithm calls this: BMSSP is
8
+ // correct on the untransformed graph (validated node-by-node against the
9
+ // Dijkstra oracle). The transform only realizes the paper's structural
10
+ // precondition for callers who want it — it changes constants, never
11
+ // correctness (issue #164, milestone 1.1.0).
12
+
13
+ // How it works: split each vertex into one "port" copy per incident edge
14
+ // endpoint and thread those copies onto a zero-weight directed cycle. A cycle
15
+ // gives every copy exactly one incoming and one outgoing cycle edge; a copy
16
+ // that additionally hosts a single original endpoint therefore reaches
17
+ // degree 2 on that one side and stays at 1 on the other — never more. Because
18
+ // the cycle costs nothing to traverse, all copies of a vertex are mutually
19
+ // reachable at zero added distance, so each copy's shortest distance equals
20
+ // the original vertex's: the rewrite is distance-preserving.
21
+
22
+ /**
23
+ * Rewrite a directed graph so every vertex has in-degree and out-degree <= 2,
24
+ * preserving all shortest-path distances.
25
+ *
26
+ * @param {Array<[number, number, number]>} graph - [from, to, weight] edges,
27
+ * the same input contract as the BMSSP constructor: an array of exact
28
+ * three-element arrays with finite numeric node IDs and finite, non-negative
29
+ * weights. An empty array is valid.
30
+ * @returns {{
31
+ * edges: Array<[number, number, number]>,
32
+ * copiesOf: Map<number, number[]>,
33
+ * originalOf: Map<number, number>,
34
+ * sourceCopy: (original: number) => number,
35
+ * collapse: (transformedDistances: Map<number, number>) => Map<number, number>
36
+ * }}
37
+ * - `edges`: the transformed graph (fresh integer copy IDs from 0).
38
+ * - `copiesOf`: original node ID -> its copy IDs, in allocation order.
39
+ * - `originalOf`: copy ID -> the original node ID it belongs to.
40
+ * - `sourceCopy(original)`: a canonical copy to start a run from (any copy
41
+ * works — the zero-weight cycle equalizes them); throws for an unknown
42
+ * node.
43
+ * - `collapse(distances)`: fold a transformed-graph distance map back onto
44
+ * the original node IDs (the min over each vertex's copies; all copies
45
+ * share the same value, so the min is exact).
46
+ */
47
+ function constantDegreeTransform(graph) {
48
+ if (!Array.isArray(graph)) {
49
+ throw new Error("Input graph must be an array of edges");
50
+ }
51
+
52
+ let nextCopyId = 0;
53
+ // original node ID -> its copy IDs, in allocation order
54
+ const copiesOf = new Map();
55
+ // copy ID -> the original node ID it belongs to
56
+ const originalOf = new Map();
57
+ // per original edge, the out-port copy of `from` and the in-port copy of `to`
58
+ const outCopyForEdge = new Array(graph.length);
59
+ const inCopyForEdge = new Array(graph.length);
60
+
61
+ const allocateCopy = (originalId) => {
62
+ const id = nextCopyId;
63
+ nextCopyId += 1;
64
+ originalOf.set(id, originalId);
65
+ let copies = copiesOf.get(originalId);
66
+ if (copies === undefined) {
67
+ copies = [];
68
+ copiesOf.set(originalId, copies);
69
+ }
70
+ copies.push(id);
71
+ return id;
72
+ };
73
+
74
+ // One copy per edge endpoint, allocated in edge order so the whole transform
75
+ // is deterministic: the out-port at `from`, the in-port at `to`. Validate
76
+ // exactly like the BMSSP constructor so the transform can front any graph.
77
+ graph.forEach((edge, index) => {
78
+ if (!Array.isArray(edge) || edge.length !== 3) {
79
+ throw new Error(`Edge at index ${index} must be [from, to, weight]`);
80
+ }
81
+ const [from, to, weight] = edge;
82
+ if (!Number.isFinite(from) || !Number.isFinite(to)) {
83
+ throw new Error(`Edge at index ${index} must have numeric node IDs`);
84
+ }
85
+ if (!Number.isFinite(weight) || weight < 0) {
86
+ throw new Error(
87
+ `Edge at index ${index} must have a non-negative numeric weight`,
88
+ );
89
+ }
90
+ outCopyForEdge[index] = allocateCopy(from);
91
+ inCopyForEdge[index] = allocateCopy(to);
92
+ });
93
+
94
+ const edges = [];
95
+
96
+ // Zero-weight directed cycle through each vertex's copies. A vertex with a
97
+ // single incident endpoint has one copy and needs no cycle.
98
+ for (const copies of copiesOf.values()) {
99
+ if (copies.length < 2) continue;
100
+ for (let i = 0; i < copies.length; i += 1) {
101
+ const next = copies[(i + 1) % copies.length];
102
+ edges.push([copies[i], next, 0]);
103
+ }
104
+ }
105
+
106
+ // Each original edge connects its own out-copy to its own in-copy, weight
107
+ // unchanged.
108
+ graph.forEach(([, , weight], index) => {
109
+ edges.push([outCopyForEdge[index], inCopyForEdge[index], weight]);
110
+ });
111
+
112
+ const sourceCopy = (original) => {
113
+ const copies = copiesOf.get(original);
114
+ if (copies === undefined) {
115
+ throw new Error(`Node ${original} is not in the graph`);
116
+ }
117
+ return copies[0];
118
+ };
119
+
120
+ const collapse = (transformedDistances) => {
121
+ const result = new Map();
122
+ for (const [original, copies] of copiesOf) {
123
+ let best = Infinity;
124
+ for (const copy of copies) {
125
+ const d = transformedDistances.get(copy);
126
+ if (d !== undefined && d < best) best = d;
127
+ }
128
+ result.set(original, best);
129
+ }
130
+ return result;
131
+ };
132
+
133
+ return { edges, copiesOf, originalOf, sourceCopy, collapse };
134
+ }
135
+
136
+ export { constantDegreeTransform };