bmssp 0.19.0 → 1.0.1

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.
Files changed (3) hide show
  1. package/README.md +39 -17
  2. package/package.json +1 -1
  3. package/src/bmssp.mjs +165 -6
package/README.md CHANGED
@@ -15,9 +15,11 @@ first to beat Dijkstra's O(m + n·log n) bound on sparse directed graphs.
15
15
 
16
16
  ## Project status
17
17
 
18
- The project is built piece by piece against the paper, with every building block shipped,
19
- tested, and released individually. Current focus: milestone
20
- [`1.0.0` first end-to-end functional BMSSP](https://github.com/Sirivasv/bmssp-js/milestones).
18
+ **The algorithm is functional end-to-end as of `1.0.0`** 🎉 `calculateShortestPaths()`
19
+ computes distances via the paper's method (FindPivots → block list → recursive BMSSP with
20
+ the bounded base case), validated node-by-node against a Dijkstra oracle, including on a
21
+ real 2-million-node road network. The project was built piece by piece against the paper,
22
+ with every building block shipped, tested, and released individually:
21
23
 
22
24
  | Building block (paper) | Where | Status |
23
25
  | --- | --- | --- |
@@ -27,12 +29,19 @@ tested, and released individually. Current focus: milestone
27
29
  | Indexed binary min-heap ([#41](https://github.com/Sirivasv/bmssp-js/issues/41)) | `src/heap.mjs` | ✅ done |
28
30
  | `BaseCase(B, S)` — Algorithm 2, bounded mini-Dijkstra ([#40](https://github.com/Sirivasv/bmssp-js/issues/40)) | `src/baseCase.mjs` | ✅ done |
29
31
  | `FindPivots(B, S)` — Algorithm 1, frontier shrinking ([#44](https://github.com/Sirivasv/bmssp-js/issues/44)) | `src/findPivots.mjs` | ✅ done |
30
- | `BMSSP(l, B, S)` — Algorithm 3, the main recursion ([#43](https://github.com/Sirivasv/bmssp-js/issues/43)) | | 🔨 next up the last piece |
31
-
32
- > **Honest note:** until [#43](https://github.com/Sirivasv/bmssp-js/issues/43) lands,
33
- > `calculateShortestPaths()` computes distances with the reference Dijkstra implementation.
34
- > The shipped BMSSP building blocks (block list, heap, base case, pivot finding) are fully
35
- > tested but not yet wired into the public entry point.
32
+ | `BMSSP(l, B, S)` — Algorithm 3, the main recursion ([#43](https://github.com/Sirivasv/bmssp-js/issues/43)) | `src/bmssp.mjs` | done**1.0.0** |
33
+
34
+ > **Honest note:** the paper's win is asymptotic, and this repo optimizes for correctness
35
+ > and readability, not raw speed. Measured head-to-head (algorithm time only, graph
36
+ > loading excluded see [benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md)):
37
+ > Dijkstra still wins on wall-clock at every practical size, though the gap narrows as
38
+ > sparse graphs grow (2.5× at 50k nodes → 1.57× at 2M). But in the paper's own metric —
39
+ > **comparisons between path lengths** — BMSSP does **fewer comparisons than Dijkstra
40
+ > from about n = 1M on sparse graphs** (0.91× at n = 2M), and the advantage grows with
41
+ > size: the "sorting barrier" is measurably broken; what remains is JS constant factors.
42
+ > Where inputs violate the paper's distinct-path-lengths assumption (e.g. zero-weight
43
+ > edges), documented tie guards keep the results correct
44
+ > ([#163](https://github.com/Sirivasv/bmssp-js/issues/163) tracks a principled tie-break).
36
45
 
37
46
  ## How it works (in two ideas)
38
47
 
@@ -44,7 +53,9 @@ tested, and released individually. Current focus: milestone
44
53
  ordered *between* blocks but unsorted *within* them — enough to repeatedly pull the next
45
54
  closest batch without paying the Θ(log n)-per-vertex "sorting barrier."
46
55
 
47
- The asymptotic win is theoretical (the crossover point is astronomically large); this repo
56
+ The wall-clock crossover point is astronomically large, but the asymptotics are real: in
57
+ measured comparison counts this implementation already beats Dijkstra past ~1M nodes on
58
+ sparse graphs ([benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md)). This repo
48
59
  optimizes for a **correct, readable, well-tested** implementation, validated line-by-line
49
60
  against a Dijkstra oracle — not for raw speed.
50
61
 
@@ -101,17 +112,28 @@ npm run lint # Prettier + ESLint
101
112
  npm run bench # seeded micro-benchmarks (see benchmarks/README.md)
102
113
  ```
103
114
 
115
+ The measured BMSSP-vs-Dijkstra head-to-head — wall-clock and comparison counts, with
116
+ methodology — lives in [benchmarks/HEAD-TO-HEAD.md](benchmarks/HEAD-TO-HEAD.md)
117
+ ([#170](https://github.com/Sirivasv/bmssp-js/issues/170) tracks harness integration).
118
+
104
119
  The test suite's core contract: for every node, BMSSP's distances must equal the Dijkstra
105
- oracle's — including on `test/roadNet-CA.txt`, a real road network from SNAP.
120
+ oracle's — including on `test/roadNet-CA.txt`, a real road network from SNAP. A seeded
121
+ property/fuzz suite (`test/fuzz.test.mjs`) hammers that contract across eight graph shapes
122
+ (sparse/dense random, grids, chains, stars, DAGs, disconnected forests, multigraphs),
123
+ extreme weight regimes (all-zero, mixed zero/huge, exact floats), and direct multi-source
124
+ bounded calls checked against per-source oracles. It runs in `npm test` by default; crank
125
+ the volume with the `FUZZ_ROUNDS` multiplier (e.g. `FUZZ_ROUNDS=25 npm test -- test/fuzz.test.mjs`
126
+ checks several thousand graphs in a few seconds). Failures always report the seed that
127
+ produced them.
106
128
 
107
129
  ## Roadmap
108
130
 
109
- | Milestone | Theme |
110
- | --- | --- |
111
- | [`1.0.0`](https://github.com/Sirivasv/bmssp-js/milestones) | First end-to-end functional BMSSP (issues #40–#45) |
112
- | `1.1.0` | Correctness hardening — fuzz tests, edge cases, tie-breaking, input validation |
113
- | `1.2.0` | Performance & ergonomics — exact Lemma 3.3 asymptotics, path reconstruction, BMSSP-vs-Dijkstra benchmarks |
114
- | `2.0.0` | API generalization — public multi-source/bounded entry point, flexible inputs |
131
+ | Milestone | Theme | Status |
132
+ | --- | --- | --- |
133
+ | [`1.0.0`](https://github.com/Sirivasv/bmssp-js/milestones) | First end-to-end functional BMSSP (issues #40–#45) | ✅ done |
134
+ | `1.1.0` | Correctness hardening — fuzz tests, edge cases, tie-breaking, input validation | 🔨 current focus |
135
+ | `1.2.0` | Performance & ergonomics — exact Lemma 3.3 asymptotics, path reconstruction, BMSSP-vs-Dijkstra benchmarks | planned |
136
+ | `2.0.0` | API generalization — public multi-source/bounded entry point, flexible inputs | planned |
115
137
 
116
138
  Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and the
117
139
  [`help wanted` / `good first issue` labels](https://github.com/Sirivasv/bmssp-js/issues).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmssp",
3
- "version": "0.19.0",
3
+ "version": "1.0.1",
4
4
  "description": "Javascript package implementation of the bmssp algorithm.",
5
5
  "main": "index.mjs",
6
6
  "keywords": [
package/src/bmssp.mjs CHANGED
@@ -1,4 +1,6 @@
1
- import { dijkstra } from "./dijkstra.mjs";
1
+ import { baseCase } from "./baseCase.mjs";
2
+ import { findPivots } from "./findPivots.mjs";
3
+ import { BlockList } from "./blockList.mjs";
2
4
 
3
5
  class BMSSP {
4
6
  constructor(inputGraph) {
@@ -27,6 +29,9 @@ class BMSSP {
27
29
 
28
30
  // Initialize shortest paths map
29
31
  this.initializeShortestPaths();
32
+
33
+ // Derive the paper's k / t parameters and the top recursion level
34
+ this.deriveParameters();
30
35
  }
31
36
 
32
37
  // Method to (re)build the adjacency map from this.graph.
@@ -59,7 +64,162 @@ class BMSSP {
59
64
  }
60
65
  }
61
66
 
62
- // Method to calculate shortest paths from startNode using Dijkstra
67
+ /**
68
+ * Derive the paper's parameters from n = |V| and store them on the
69
+ * instance:
70
+ * - k = max(1, ⌊(log₂ n)^(1/3)⌋) — Bellman-Ford rounds in FindPivots and
71
+ * the BaseCase settle cap,
72
+ * - t = max(1, ⌊(log₂ n)^(2/3)⌋) — branching / level sizing,
73
+ * - topLevel = max(1, ⌈log₂ n / t⌉) — level of the top BMSSP call.
74
+ * Everything is clamped to >= 1 so tiny graphs stay out of degenerate
75
+ * regimes: correctness never depends on the asymptotics.
76
+ */
77
+ deriveParameters() {
78
+ const logn = Math.log2(Math.max(2, this.nodeIDs.size));
79
+ this.k = Math.max(1, Math.floor(logn ** (1 / 3)));
80
+ this.t = Math.max(1, Math.floor(logn ** (2 / 3)));
81
+ this.topLevel = Math.max(1, Math.ceil(logn / this.t));
82
+ }
83
+
84
+ /**
85
+ * BMSSP(l, B, S) — Algorithm 3 of "Breaking the Sorting Barrier for
86
+ * Directed Single-Source Shortest Paths": the main bounded multi-source
87
+ * recursion, wiring FindPivots (Algorithm 1), the Lemma 3.3 BlockList and
88
+ * BaseCase (Algorithm 2) together.
89
+ *
90
+ * Preconditions (the top-level call satisfies them trivially):
91
+ * - Every vertex in S is complete (this.shortestPaths holds its true
92
+ * distance), and every incomplete vertex v with d(v) < B has a shortest
93
+ * path through some complete vertex of S.
94
+ *
95
+ * Distance estimates live in this.shortestPaths (the paper's d̂[·]) and
96
+ * are relaxed in place at every level.
97
+ *
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.
103
+ * Every returned vertex is complete either way.
104
+ *
105
+ * @param {number} l - Recursion level; 0 delegates to baseCase
106
+ * @param {number} B - Strict upper bound on the distances in scope (Infinity is allowed)
107
+ * @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
110
+ */
111
+ bmssp(l, B, S) {
112
+ const dHat = this.shortestPaths;
113
+ if (l === 0) {
114
+ return baseCase(B, S, dHat, this.adjacency, this.k);
115
+ }
116
+
117
+ // Shrink the frontier: only the pivots are worth recursing on, and W
118
+ // 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;
130
+ 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;
135
+ }
136
+ }
137
+
138
+ const U = new Set();
139
+ const workloadCap = this.k * 2 ** (l * this.t);
140
+
141
+ while (U.size < workloadCap && !D.isEmpty()) {
142
+ // 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
+ }
167
+
168
+ lastBound = BiPrime;
169
+ for (const v of Ui) U.add(v);
170
+
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)
174
+ const K = [];
175
+ for (const u of Ui) {
176
+ const du = dHat.get(u);
177
+ 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
+ }
192
+ }
193
+ }
194
+ }
195
+ // Batch members the child did not complete (d̂ 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.
199
+ for (const x of Si) {
200
+ 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);
206
+ }
207
+ }
208
+ if (K.length > 0) D.batchPrepend(K);
209
+ }
210
+
211
+ // B' <- min(last Bi', B); fold in the FindPivots batch below it
212
+ const bound = Math.min(lastBound, B);
213
+ for (const x of W) {
214
+ if (dHat.get(x) < bound) U.add(x);
215
+ }
216
+ return { bound, vertices: U };
217
+ }
218
+
219
+ // Method to calculate shortest paths from startNode via the BMSSP
220
+ // recursion (Algorithm 3). The top-level call BMSSP(topLevel, ∞, {start})
221
+ // is always a successful execution, so it completes every reachable
222
+ // vertex; unreachable ones keep their Infinity estimate.
63
223
  calculateShortestPaths(startNode) {
64
224
  // To clean the state before calculation
65
225
  this.initializeShortestPaths();
@@ -69,10 +229,9 @@ class BMSSP {
69
229
  throw new Error("Start node not found in the graph");
70
230
  }
71
231
 
72
- const result = dijkstra(this.graph, this.nodeIDs, startNode);
73
- result.forEach((distance, nodeId) => {
74
- this.shortestPaths.set(nodeId, distance);
75
- });
232
+ // The source is complete at distance 0; everything else is Infinity
233
+ this.shortestPaths.set(startNode, 0);
234
+ this.bmssp(this.topLevel, Infinity, new Set([startNode]));
76
235
  }
77
236
  }
78
237