bmssp 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  compareKeyParts,
3
3
  toBound,
4
- makeTies,
5
4
  relaxEdge,
6
5
  RELAX_LOST,
7
6
  } from "./tieBreak.mjs";
@@ -15,17 +14,19 @@ import {
15
14
  * the pivots need to be carried into the deeper BMSSP recursion.
16
15
  *
17
16
  * Preconditions (guaranteed by the caller, Algorithm 3):
18
- * - Every vertex in S is complete (dHat holds its true distance).
17
+ * - Every vertex in S is complete (labels.dist holds its true distance).
19
18
  * - Every incomplete vertex v with d(v) < B has a shortest path through some
20
19
  * complete vertex in S.
21
20
  *
22
- * Distance estimates are read from and written into dHat in place exactly
23
- * like the paper's global d̂[·] labels, so improvements made here are visible
24
- * to the levels above. Relaxation is canonical (#163, src/tieBreak.mjs):
25
- * d̂, hops and preds move together under the composite [length, hops, id]
26
- * order, and an exact-equality result re-admits a vertex to W through its
27
- * one canonical label-setter (the paper's `≤` made deterministic). The `< B`
28
- * test only gates membership in W; updates are not gated.
21
+ * Since #205 the engine works on dense vertex indices: S holds indices, the
22
+ * graph is the class's CSR bundle, and the labels are the shared typed
23
+ * arrays (see tieBreak's makeLabels) updated in place, exactly like the
24
+ * paper's global d̂[·], so improvements made here are visible to the levels
25
+ * above. Relaxation is canonical (#163): dist, hops and preds move together
26
+ * under the composite [length, hops, index] order, and an exact-equality
27
+ * result re-admits a vertex to W through its one canonical label-setter (the
28
+ * paper's `≤` made deterministic). The `< B` test only gates membership in
29
+ * W; label updates are not gated.
29
30
  *
30
31
  * The paper's tight-edge forest falls out of the canonical labels: each
31
32
  * vertex of W \ S hangs off its recorded canonical predecessor, which is
@@ -44,19 +45,20 @@ import {
44
45
  *
45
46
  * @param {number|[number, number, *]} B - Strict upper bound gating membership
46
47
  * in W: a number (Infinity is allowed) or a composite bound
47
- * @param {Set<*>|Iterable<*>} S - Non-empty set of complete frontier sources
48
- * @param {Map<*, number>} dHat - Global distance estimates d̂[·], updated in place
49
- * @param {Map<*, Array<[*, number]>>} adjacency - nodeId -> outgoing [to, weight] edges
48
+ * @param {Set<number>|Iterable<number>} S - Non-empty set of complete frontier
49
+ * source indices
50
+ * @param {{ dist: Float64Array, hops: Uint32Array, preds: Int32Array }} labels
51
+ * - Engine label state (d̂[·] and tie-break labels), updated in place
52
+ * @param {{ offsets: Uint32Array, targets: Uint32Array, weights: Float64Array }} csr
53
+ * - The graph in CSR layout over dense indices
50
54
  * @param {number} k - Relaxation rounds / tree-size threshold, >= 1 (floored);
51
55
  * the paper's ⌊log^(1/3) n⌋
52
- * @param {{ hops: Map<*, number>, preds: Map<*, *> }} [ties] - Canonical
53
- * tie-break labels updated alongside dHat; fresh throwaway maps by default
54
- * @returns {{ pivots: Set<*>, W: Set<*> }} The pivot subset of S and the set W
55
- * of vertices touched below B (W always contains S)
56
+ * @returns {{ pivots: Set<number>, W: Set<number> }} The pivot subset of S and
57
+ * the set W of vertex indices touched below B (W always contains S)
56
58
  * @throws {Error} If k is not a number >= 1, S is empty, or any source has no
57
59
  * finite distance estimate
58
60
  */
59
- function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
61
+ function findPivots(B, S, labels, csr, k) {
60
62
  if (typeof k !== "number" || Number.isNaN(k) || k < 1) {
61
63
  throw new Error("k must be a number >= 1");
62
64
  }
@@ -66,12 +68,12 @@ function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
66
68
  throw new Error("S must contain at least one source node");
67
69
  }
68
70
  for (const x of sources) {
69
- const distance = dHat.get(x);
70
- if (typeof distance !== "number" || !Number.isFinite(distance)) {
71
+ if (!Number.isFinite(labels.dist[x])) {
71
72
  throw new Error("every source must have a finite distance estimate");
72
73
  }
73
74
  }
74
75
  const boundKey = toBound(B);
76
+ const { offsets, targets, weights } = csr;
75
77
 
76
78
  // W accumulates everything relaxed below B; layer is the paper's W_{i-1}
77
79
  const sourceSet = new Set(sources);
@@ -81,20 +83,17 @@ function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
81
83
  for (let i = 1; i <= rounds; i += 1) {
82
84
  const nextLayer = new Set();
83
85
  for (const u of layer) {
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];
89
- // Canonical relaxation: d̂ updates are not gated by B (paper), and
90
- // both improvements and exact canonical equality admit v to the
86
+ for (let e = offsets[u]; e < offsets[u + 1]; e += 1) {
87
+ const v = targets[e];
88
+ // Canonical relaxation: label updates are not gated by B (paper),
89
+ // and both improvements and exact canonical equality admit v to the
91
90
  // next layer when its key is below B. Non-lost ⇒ v's stored label
92
91
  // is the candidate, so the W gate compares the unpacked stored
93
92
  // components — no key allocation (#168).
94
- const result = relaxEdge(u, v, edge[1], dHat, ties);
93
+ const result = relaxEdge(u, v, weights[e], labels);
95
94
  if (
96
95
  result !== RELAX_LOST &&
97
- compareKeyParts(dHat.get(v), ties.hops.get(v) ?? 0, v, boundKey) < 0
96
+ compareKeyParts(labels.dist[v], labels.hops[v], v, boundKey) < 0
98
97
  ) {
99
98
  nextLayer.add(v);
100
99
  }
@@ -114,7 +113,7 @@ function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
114
113
  const children = new Map();
115
114
  for (const v of W) {
116
115
  if (sourceSet.has(v)) continue;
117
- const parent = ties.preds.get(v);
116
+ const parent = labels.preds[v];
118
117
  if (!children.has(parent)) children.set(parent, []);
119
118
  children.get(parent).push(v);
120
119
  }
package/src/graph.mjs ADDED
@@ -0,0 +1,174 @@
1
+ // #172 — typed / flexible graph inputs.
2
+ //
3
+ // A small builder (`Graph`) plus a normalizer (`normalizeGraphInput`) that
4
+ // lets the BMSSP constructor accept several input shapes and reduce them to
5
+ // one canonical internal form: `{ edges: [[from, to, weight], ...],
6
+ // vertices: [id, ...] }`.
7
+ //
8
+ // `vertices` carries the EXPLICIT vertex universe — every node the caller
9
+ // declared, including isolated ones with no incident edges. Edge endpoints
10
+ // are still folded in by the BMSSP constructor, so an empty `vertices`
11
+ // (the plain edge-list form) reproduces the pre-#172 "infer nodes from
12
+ // edges" behavior exactly.
13
+ //
14
+ // Node-ID semantics are unchanged from earlier versions: IDs are finite
15
+ // numbers. Value validation (finite IDs, finite non-negative weights) with
16
+ // the "Edge at index N" messages stays in the BMSSP constructor so there is
17
+ // a single source of truth; the pieces here validate only what they must to
18
+ // build a well-formed normalized shape (and the builder validates eagerly at
19
+ // the call site for a friendlier failure).
20
+
21
+ /**
22
+ * A tiny mutable graph builder. Declare vertices (including isolated ones)
23
+ * and directed weighted edges, then hand the instance straight to
24
+ * `new BMSSP(graph)`.
25
+ *
26
+ * All mutators return `this`, so calls chain:
27
+ * `new Graph().addEdge(0, 1, 50).addEdge(1, 2, 75).addVertex(9)`.
28
+ *
29
+ * Node IDs must be finite numbers and weights finite and non-negative — the
30
+ * same contract the edge-array form has always enforced.
31
+ */
32
+ class Graph {
33
+ constructor() {
34
+ // Declared vertex universe (numbers). addEdge folds in its endpoints too.
35
+ this._vertices = new Set();
36
+ // Declared directed edges as [from, to, weight] triples.
37
+ this._edges = [];
38
+ }
39
+
40
+ /**
41
+ * Declare a vertex. Safe to call for a node that already exists (idempotent)
42
+ * and the only way to introduce an isolated vertex (one with no edges).
43
+ * @param {number} id finite node ID
44
+ * @returns {Graph} this
45
+ */
46
+ addVertex(id) {
47
+ if (!Number.isFinite(id)) {
48
+ throw new Error("Graph.addVertex: node id must be a finite number");
49
+ }
50
+ this._vertices.add(id);
51
+ return this;
52
+ }
53
+
54
+ /**
55
+ * Add a directed edge `from -> to` of the given weight. Both endpoints are
56
+ * declared automatically.
57
+ * @param {number} from finite source node ID
58
+ * @param {number} to finite target node ID
59
+ * @param {number} weight finite, non-negative edge weight
60
+ * @returns {Graph} this
61
+ */
62
+ addEdge(from, to, weight) {
63
+ if (!Number.isFinite(from) || !Number.isFinite(to)) {
64
+ throw new Error("Graph.addEdge: node ids must be finite numbers");
65
+ }
66
+ if (!Number.isFinite(weight) || weight < 0) {
67
+ throw new Error(
68
+ "Graph.addEdge: weight must be a finite, non-negative number",
69
+ );
70
+ }
71
+ this._vertices.add(from);
72
+ this._vertices.add(to);
73
+ this._edges.push([from, to, weight]);
74
+ return this;
75
+ }
76
+
77
+ /** @param {number} id @returns {boolean} whether the vertex is declared */
78
+ hasVertex(id) {
79
+ return this._vertices.has(id);
80
+ }
81
+
82
+ /** @returns {number} number of declared vertices */
83
+ get vertexCount() {
84
+ return this._vertices.size;
85
+ }
86
+
87
+ /** @returns {number} number of declared edges */
88
+ get edgeCount() {
89
+ return this._edges.length;
90
+ }
91
+
92
+ /**
93
+ * The canonical normalized form the BMSSP constructor consumes. Returns
94
+ * fresh copies so later mutation of this builder cannot affect a graph
95
+ * already constructed from it.
96
+ * @returns {{ edges: number[][], vertices: number[] }}
97
+ */
98
+ toNormalized() {
99
+ return {
100
+ edges: this._edges.map((edge) => [...edge]),
101
+ vertices: [...this._vertices],
102
+ };
103
+ }
104
+ }
105
+
106
+ function isIterable(value) {
107
+ return value != null && typeof value[Symbol.iterator] === "function";
108
+ }
109
+
110
+ /**
111
+ * Reduce an adjacency form to `{ edges, vertices }`. Each entry maps a source
112
+ * node to an iterable of `[to, weight]` pairs; the source is always declared
113
+ * (so a node with an empty/absent neighbor list becomes an isolated vertex).
114
+ * When `coerceKeys` is set (plain-object form, whose keys are strings), the
115
+ * source key is converted to a number — finiteness is then checked by the
116
+ * BMSSP constructor's vertex validation.
117
+ */
118
+ function adjacencyToNormalized(entries, coerceKeys) {
119
+ const edges = [];
120
+ const vertices = [];
121
+ for (const [rawFrom, neighbors] of entries) {
122
+ const from = coerceKeys ? Number(rawFrom) : rawFrom;
123
+ vertices.push(from);
124
+ if (neighbors == null) continue;
125
+ if (!isIterable(neighbors)) {
126
+ throw new Error(
127
+ `Adjacency list for node ${rawFrom} must be an iterable of [to, weight]`,
128
+ );
129
+ }
130
+ for (const pair of neighbors) {
131
+ if (!Array.isArray(pair) || pair.length !== 2) {
132
+ throw new Error(
133
+ `Adjacency entry for node ${rawFrom} must be [to, weight]`,
134
+ );
135
+ }
136
+ edges.push([from, pair[0], pair[1]]);
137
+ }
138
+ }
139
+ return { edges, vertices };
140
+ }
141
+
142
+ /**
143
+ * Normalize any accepted graph input into `{ edges, vertices }`:
144
+ * - a `Graph` builder instance,
145
+ * - an edge array `[[from, to, weight], ...]` (vertices inferred from edges),
146
+ * - an adjacency `Map<from, Iterable<[to, weight]>>`, or
147
+ * - a plain adjacency object `{ from: [[to, weight], ...] }` (numeric-string
148
+ * keys, coerced to numbers).
149
+ *
150
+ * Only structural checks live here; per-edge value validation stays in the
151
+ * BMSSP constructor so its "Edge at index N" messages remain the single
152
+ * source of truth.
153
+ * @param {*} input
154
+ * @returns {{ edges: number[][], vertices: number[] }}
155
+ */
156
+ function normalizeGraphInput(input) {
157
+ if (input instanceof Graph) {
158
+ return input.toNormalized();
159
+ }
160
+ if (Array.isArray(input)) {
161
+ return { edges: input, vertices: [] };
162
+ }
163
+ if (input instanceof Map) {
164
+ return adjacencyToNormalized(input.entries(), false);
165
+ }
166
+ if (input !== null && typeof input === "object") {
167
+ return adjacencyToNormalized(Object.entries(input), true);
168
+ }
169
+ throw new Error(
170
+ "Input graph must be an edge array, an adjacency map/object, or a Graph instance",
171
+ );
172
+ }
173
+
174
+ export { Graph, normalizeGraphInput };
package/src/tieBreak.mjs CHANGED
@@ -38,9 +38,11 @@
38
38
  * source is a hop-0 root) and "no predecessor" (which never loses a tie).
39
39
  */
40
40
 
41
- // Sentinel predecessor for sources: compares below every real id, so a
42
- // source's own label never loses an equal-(length, hops) comparison.
43
- const NO_PRED = -Infinity;
41
+ // Sentinel predecessor for sources: compares below every real vertex index,
42
+ // so a source's own label never loses an equal-(length, hops) comparison.
43
+ // Since #205 the engine works on dense vertex indices (>= 0), so -1 is a
44
+ // valid sentinel AND fits the Int32Array holding the predecessors.
45
+ const NO_PRED = -1;
44
46
 
45
47
  // Running count of compareKeys calls — the paper's cost metric ("comparisons
46
48
  // between path lengths"). Every BMSSP-side comparison funnels through
@@ -110,7 +112,9 @@ function toBound(B) {
110
112
  }
111
113
 
112
114
  /**
113
- * Bundle (or create) the tie-break label maps that accompany d̂.
115
+ * Bundle (or create) the tie-break label maps that accompany d̂ at the PUBLIC
116
+ * boundary (the BMSSP class mirrors the engine's arrays into these Maps,
117
+ * keyed by original node ids).
114
118
  * @param {Map<*, number>} [hops] - Canonical path edge counts
115
119
  * @param {Map<*, *>} [preds] - Canonical predecessor of each vertex
116
120
  * @returns {{ hops: Map<*, number>, preds: Map<*, *> }}
@@ -119,6 +123,31 @@ function makeTies(hops = new Map(), preds = new Map()) {
119
123
  return { hops, preds };
120
124
  }
121
125
 
126
+ /**
127
+ * Allocate the engine's label state for n vertices (#205): d̂, hops and
128
+ * canonical predecessors as typed arrays over dense vertex indices.
129
+ * Unlabeled vertices read as distance Infinity, hop 0, no predecessor —
130
+ * the same defaults the pre-#205 Maps gave via `?? Infinity` / `?? 0`.
131
+ * @param {number} n - Vertex count
132
+ * @returns {{ dist: Float64Array, hops: Uint32Array, preds: Int32Array }}
133
+ */
134
+ function makeLabels(n) {
135
+ const dist = new Float64Array(n).fill(Infinity);
136
+ const hops = new Uint32Array(n);
137
+ const preds = new Int32Array(n).fill(NO_PRED);
138
+ return { dist, hops, preds };
139
+ }
140
+
141
+ /**
142
+ * The frontier-ordering key of vertex index v under the engine labels.
143
+ * @param {number} v - Dense vertex index
144
+ * @param {{ dist: Float64Array, hops: Uint32Array }} labels
145
+ * @returns {[number, number, number]} [dist[v], hops[v], v]
146
+ */
147
+ function labelKey(v, labels) {
148
+ return [labels.dist[v], labels.hops[v], v];
149
+ }
150
+
122
151
  /**
123
152
  * The frontier-ordering key of a vertex under its current labels.
124
153
  * @param {*} v - Vertex id
@@ -155,11 +184,16 @@ function orderKey(v, dHat, ties) {
155
184
  * with orderKey(v, dHat, ties) — only on the rare paths that enqueue v,
156
185
  * instead of on every attempt.
157
186
  *
158
- * @param {*} u - Edge tail (its labels are read)
159
- * @param {*} v - Edge head (its labels may be updated)
187
+ * Since #205 the labels live in typed arrays over dense vertex indices
188
+ * (see makeLabels); u and v are indices, and index order equals original-id
189
+ * order (the BMSSP class assigns indices by sorted id), so the canonical
190
+ * choices are identical to the pre-#205 Map engine's.
191
+ *
192
+ * @param {number} u - Edge tail index (its labels are read)
193
+ * @param {number} v - Edge head index (its labels may be updated)
160
194
  * @param {number} weight - Edge weight, >= 0
161
- * @param {Map<*, number>} dHat - Distance estimates d̂[·], updated in place
162
- * @param {{ hops: Map<*, number>, preds: Map<*, *> }} ties - Updated in place
195
+ * @param {{ dist: Float64Array, hops: Uint32Array, preds: Int32Array }} labels
196
+ * - Engine label state, updated in place
163
197
  * @param {[number, number, *]} [bound] - Optional gate: skip (no d̂ update)
164
198
  * unless the resulting order key would be strictly below this bound
165
199
  * @returns {number} RELAX_IMPROVED when the labels were updated,
@@ -167,34 +201,34 @@ function orderKey(v, dHat, ties) {
167
201
  * (the re-enqueue signal), RELAX_LOST when the candidate loses or the
168
202
  * bound gates it (labels untouched)
169
203
  */
170
- function relaxEdge(u, v, weight, dHat, ties, bound) {
171
- const length = dHat.get(u) + weight;
172
- const hopCount = (ties.hops.get(u) ?? 0) + 1;
204
+ function relaxEdge(u, v, weight, labels, bound) {
205
+ const length = labels.dist[u] + weight;
206
+ const hopCount = labels.hops[u] + 1;
173
207
  if (bound !== undefined && compareKeyParts(length, hopCount, v, bound) >= 0) {
174
208
  return RELAX_LOST;
175
209
  }
176
210
  // Candidate path key [length, hopCount, u] vs. v's current path key
177
- // [[v], hops[v], preds[v] ?? NO_PRED] — compareKeys inlined on the
178
- // unpacked components (one counted comparison, no arrays)
211
+ // [dist[v], hops[v], preds[v]] — compareKeys inlined on the unpacked
212
+ // components (one counted comparison, no arrays)
179
213
  comparisonCount += 1;
180
214
  let cmp;
181
- const currentLength = dHat.get(v) ?? Infinity;
215
+ const currentLength = labels.dist[v];
182
216
  if (length !== currentLength) {
183
217
  cmp = length < currentLength ? -1 : 1;
184
218
  } else {
185
- const currentHops = ties.hops.get(v) ?? 0;
219
+ const currentHops = labels.hops[v];
186
220
  if (hopCount !== currentHops) {
187
221
  cmp = hopCount < currentHops ? -1 : 1;
188
222
  } else {
189
- const currentPred = ties.preds.has(v) ? ties.preds.get(v) : NO_PRED;
223
+ const currentPred = labels.preds[v];
190
224
  cmp = u !== currentPred ? (u < currentPred ? -1 : 1) : 0;
191
225
  }
192
226
  }
193
227
  if (cmp > 0) return RELAX_LOST;
194
228
  if (cmp === 0) return RELAX_EQUAL;
195
- dHat.set(v, length);
196
- ties.hops.set(v, hopCount);
197
- ties.preds.set(v, u);
229
+ labels.dist[v] = length;
230
+ labels.hops[v] = hopCount;
231
+ labels.preds[v] = u;
198
232
  return RELAX_IMPROVED;
199
233
  }
200
234
 
@@ -209,7 +243,9 @@ export {
209
243
  compareKeyParts,
210
244
  toBound,
211
245
  makeTies,
246
+ makeLabels,
212
247
  orderKey,
248
+ labelKey,
213
249
  relaxEdge,
214
250
  NO_PRED,
215
251
  RELAX_LOST,