bmssp 0.17.0 → 0.19.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 +84 -23
- package/package.json +1 -1
- package/src/baseCase.mjs +94 -0
- package/src/findPivots.mjs +113 -0
package/README.md
CHANGED
|
@@ -1,64 +1,125 @@
|
|
|
1
1
|
# BMSSP: Bounded Multi-Source Shortest Paths
|
|
2
|
+
|
|
2
3
|
[](https://codecov.io/gh/sirivasv/bmssp-js)
|
|
3
4
|
[](https://www.npmjs.com/package/bmssp)
|
|
4
5
|
[](https://hub.docker.com/r/sirivasv/bmssp-js)
|
|
5
6
|
[](https://github.com/sirivasv/bmssp-js/stargazers)
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
A community-driven JavaScript (ES Modules) implementation of the Tsinghua single-source
|
|
9
|
+
shortest-paths algorithm from the paper
|
|
10
|
+
["Breaking the Sorting Barrier for Directed Single-Source Shortest Paths"](https://dl.acm.org/doi/10.1145/3717823.3718179)
|
|
11
|
+
(Duan, Mao, Mao, Shu, Yin — 2025): a deterministic **O(m·log^(2/3) n)** SSSP algorithm, the
|
|
12
|
+
first to beat Dijkstra's O(m + n·log n) bound on sparse directed graphs.
|
|
13
|
+
|
|
14
|
+
**BMSSP** stands for **B**ounded **M**ulti-**S**ource **S**hortest **P**aths.
|
|
15
|
+
|
|
16
|
+
## Project status
|
|
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).
|
|
21
|
+
|
|
22
|
+
| Building block (paper) | Where | Status |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| Reference Dijkstra oracle (ground truth for tests) | `src/dijkstra.mjs` | ✅ done |
|
|
25
|
+
| O(1) adjacency map for edge relaxation ([#45](https://github.com/Sirivasv/bmssp-js/issues/45)) | `BMSSP` constructor | ✅ done |
|
|
26
|
+
| Lemma 3.3 block-based partial-sort structure `D` ([#42](https://github.com/Sirivasv/bmssp-js/issues/42)) | `src/blockList.mjs` | ✅ done |
|
|
27
|
+
| Indexed binary min-heap ([#41](https://github.com/Sirivasv/bmssp-js/issues/41)) | `src/heap.mjs` | ✅ done |
|
|
28
|
+
| `BaseCase(B, S)` — Algorithm 2, bounded mini-Dijkstra ([#40](https://github.com/Sirivasv/bmssp-js/issues/40)) | `src/baseCase.mjs` | ✅ done |
|
|
29
|
+
| `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.
|
|
36
|
+
|
|
37
|
+
## How it works (in two ideas)
|
|
38
|
+
|
|
39
|
+
1. **Shrink the frontier with pivots.** Instead of keeping every frontier vertex in a
|
|
40
|
+
priority queue like Dijkstra, run `k` rounds of Bellman-Ford-style relaxation and recurse
|
|
41
|
+
only on the "pivots" — roots of large shortest-path trees. Only ~1/k of the frontier
|
|
42
|
+
needs the expensive treatment.
|
|
43
|
+
2. **Partial sorting instead of a heap.** A block-based structure keeps batches of vertices
|
|
44
|
+
ordered *between* blocks but unsorted *within* them — enough to repeatedly pull the next
|
|
45
|
+
closest batch without paying the Θ(log n)-per-vertex "sorting barrier."
|
|
46
|
+
|
|
47
|
+
The asymptotic win is theoretical (the crossover point is astronomically large); this repo
|
|
48
|
+
optimizes for a **correct, readable, well-tested** implementation, validated line-by-line
|
|
49
|
+
against a Dijkstra oracle — not for raw speed.
|
|
17
50
|
|
|
18
51
|
## Installation
|
|
19
52
|
|
|
20
|
-
To install this package, you can use npm:
|
|
21
|
-
|
|
22
53
|
```bash
|
|
23
54
|
npm install bmssp
|
|
24
55
|
```
|
|
25
56
|
|
|
26
57
|
## Usage
|
|
27
58
|
|
|
28
|
-
|
|
59
|
+
The package is ESM-only (`.mjs`). Graphs are arrays of `[from, to, weight]` edges with
|
|
60
|
+
numeric node IDs and non-negative weights:
|
|
29
61
|
|
|
30
62
|
```javascript
|
|
31
|
-
// This is a WIP example
|
|
32
63
|
import { BMSSP } from "bmssp";
|
|
33
64
|
|
|
34
|
-
const
|
|
65
|
+
const graph = new BMSSP([
|
|
35
66
|
[0, 1, 50],
|
|
36
67
|
[1, 2, 75],
|
|
37
68
|
[0, 2, 25],
|
|
38
69
|
]);
|
|
39
70
|
|
|
40
|
-
|
|
71
|
+
graph.calculateShortestPaths(0);
|
|
72
|
+
console.log(graph.shortestPaths); // Map(3) { 0 => 0, 1 => 50, 2 => 25 }
|
|
41
73
|
```
|
|
42
74
|
|
|
43
|
-
|
|
75
|
+
A reference `dijkstra` implementation is also exported. See the `examples/` directory for
|
|
76
|
+
more.
|
|
44
77
|
|
|
45
|
-
### Using the
|
|
78
|
+
### Using the Docker image
|
|
46
79
|
|
|
47
|
-
|
|
80
|
+
Run the bundled example:
|
|
48
81
|
|
|
49
82
|
```bash
|
|
50
83
|
docker run -it sirivasv/bmssp-js:latest
|
|
51
84
|
```
|
|
52
85
|
|
|
53
|
-
Or your tests in a pre-configured environment (replace `folder-mytest/` with your
|
|
86
|
+
Or run your own tests in a pre-configured environment (replace `folder-mytest/` with your
|
|
87
|
+
tests folder and `index.mjs` with your test file):
|
|
54
88
|
|
|
55
89
|
```bash
|
|
56
90
|
docker run -it -v ./folder-mytest/:/bmssp-js/folder-mytest/ sirivasv/bmssp-js:latest node /bmssp-js/folder-mytest/index.mjs
|
|
57
91
|
```
|
|
58
92
|
|
|
59
|
-
Other
|
|
93
|
+
Other image versions are on [Docker Hub](https://hub.docker.com/r/sirivasv/bmssp-js/tags).
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npm install
|
|
99
|
+
npm test # Jest suite, incl. BMSSP-vs-Dijkstra equivalence on a real road network
|
|
100
|
+
npm run lint # Prettier + ESLint
|
|
101
|
+
npm run bench # seeded micro-benchmarks (see benchmarks/README.md)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
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.
|
|
106
|
+
|
|
107
|
+
## Roadmap
|
|
108
|
+
|
|
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 |
|
|
115
|
+
|
|
116
|
+
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and the
|
|
117
|
+
[`help wanted` / `good first issue` labels](https://github.com/Sirivasv/bmssp-js/issues).
|
|
118
|
+
|
|
119
|
+
## Other implementations on GitHub
|
|
60
120
|
|
|
61
|
-
|
|
121
|
+
<https://github.com/search?q=bmssp&type=repositories>
|
|
62
122
|
|
|
63
|
-
|
|
123
|
+
## License
|
|
64
124
|
|
|
125
|
+
[MPL-2.0](LICENSE)
|
package/package.json
CHANGED
package/src/baseCase.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { MinHeap } from "./heap.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BaseCase(B, S) — Algorithm 2 of "Breaking the Sorting Barrier for Directed
|
|
5
|
+
* Single-Source Shortest Paths". The level-0 case of the BMSSP recursion: a
|
|
6
|
+
* mini Dijkstra from the single complete source x in S, bounded above by B,
|
|
7
|
+
* that stops after settling k + 1 vertices.
|
|
8
|
+
*
|
|
9
|
+
* Preconditions (guaranteed by the caller, Algorithm 3):
|
|
10
|
+
* - S is a singleton {x} and x is complete (dHat holds its true distance).
|
|
11
|
+
* - Every incomplete vertex v with d(v) < B has a shortest path through x.
|
|
12
|
+
*
|
|
13
|
+
* Distance estimates are read from and written into dHat in place — exactly
|
|
14
|
+
* like the paper's global d̂[·] labels, so improvements made here are visible
|
|
15
|
+
* to the levels above.
|
|
16
|
+
*
|
|
17
|
+
* Two outcomes:
|
|
18
|
+
* - Full success (fewer than k + 1 vertices exist under B): every vertex
|
|
19
|
+
* with d(v) < B is settled and returned, with bound === B.
|
|
20
|
+
* - Partial (the k + 1 cap was hit): bound = the largest settled distance
|
|
21
|
+
* B' <= B, and only the strictly-closer vertices (d̂ < B') are returned.
|
|
22
|
+
* Every returned vertex is complete either way.
|
|
23
|
+
*
|
|
24
|
+
* @param {number} B - Strict upper bound on the distances to settle (Infinity is allowed)
|
|
25
|
+
* @param {Set<*>|Iterable<*>} S - Singleton set holding the complete source x
|
|
26
|
+
* @param {Map<*, number>} dHat - Global distance estimates d̂[·], updated in place
|
|
27
|
+
* @param {Map<*, Array<[*, number]>>} adjacency - nodeId -> outgoing [to, weight] edges
|
|
28
|
+
* @param {number} k - Settle cap parameter, >= 1 (floored); the paper's ⌊log^(1/3) n⌋
|
|
29
|
+
* @returns {{ bound: number, vertices: Set<*> }} The boundary B' <= B and the
|
|
30
|
+
* set U of vertices settled below it
|
|
31
|
+
* @throws {Error} If k is not a number >= 1, S is not a singleton, or the
|
|
32
|
+
* source has no finite distance estimate
|
|
33
|
+
*/
|
|
34
|
+
function baseCase(B, S, dHat, adjacency, k) {
|
|
35
|
+
if (typeof k !== "number" || Number.isNaN(k) || k < 1) {
|
|
36
|
+
throw new Error("k must be a number >= 1");
|
|
37
|
+
}
|
|
38
|
+
const cap = Math.floor(k);
|
|
39
|
+
const sources = [...S];
|
|
40
|
+
if (sources.length !== 1) {
|
|
41
|
+
throw new Error("S must contain exactly one source node");
|
|
42
|
+
}
|
|
43
|
+
const [x] = sources;
|
|
44
|
+
const sourceDistance = dHat.get(x);
|
|
45
|
+
if (typeof sourceDistance !== "number" || !Number.isFinite(sourceDistance)) {
|
|
46
|
+
throw new Error("the source must have a finite distance estimate");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// U0 in the paper: the vertices settled by this call, seeded with x
|
|
50
|
+
const settled = new Set([x]);
|
|
51
|
+
const heap = new MinHeap();
|
|
52
|
+
heap.insert(x, sourceDistance);
|
|
53
|
+
|
|
54
|
+
while (!heap.isEmpty() && settled.size < cap + 1) {
|
|
55
|
+
const { key: u, value: du } = heap.extractMin();
|
|
56
|
+
settled.add(u);
|
|
57
|
+
for (const [v, weight] of adjacency.get(u) ?? []) {
|
|
58
|
+
const candidate = du + weight;
|
|
59
|
+
// Paper relaxation: d̂[u] + w(u,v) <= d̂[v], and below the bound B
|
|
60
|
+
if (candidate <= (dHat.get(v) ?? Infinity) && candidate < B) {
|
|
61
|
+
dHat.set(v, candidate);
|
|
62
|
+
// With non-negative weights a vertex settled in this call cannot
|
|
63
|
+
// strictly improve, so an equal-sum relaxation (which the <= allows,
|
|
64
|
+
// e.g. via a zero-weight cycle) must not re-enter the heap.
|
|
65
|
+
if (settled.has(v)) continue;
|
|
66
|
+
if (heap.has(v)) {
|
|
67
|
+
heap.decreaseKey(v, candidate);
|
|
68
|
+
} else {
|
|
69
|
+
heap.insert(v, candidate);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (settled.size <= cap) {
|
|
76
|
+
// Exhausted before the cap: everything under B is settled (B' = B)
|
|
77
|
+
return { bound: B, vertices: settled };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Hit the k + 1 cap: report B' = the largest settled distance and return
|
|
81
|
+
// only the vertices strictly below it
|
|
82
|
+
let boundary = -Infinity;
|
|
83
|
+
for (const v of settled) {
|
|
84
|
+
const distance = dHat.get(v);
|
|
85
|
+
if (distance > boundary) boundary = distance;
|
|
86
|
+
}
|
|
87
|
+
const vertices = new Set();
|
|
88
|
+
for (const v of settled) {
|
|
89
|
+
if (dHat.get(v) < boundary) vertices.add(v);
|
|
90
|
+
}
|
|
91
|
+
return { bound: boundary, vertices };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { baseCase };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FindPivots(B, S) — Algorithm 1 of "Breaking the Sorting Barrier for Directed
|
|
3
|
+
* Single-Source Shortest Paths". Shrinks the frontier S: runs k rounds of
|
|
4
|
+
* Bellman-Ford-style relaxation out of S, collecting every vertex relaxed
|
|
5
|
+
* below B into W, then identifies the pivots — the vertices of S that root a
|
|
6
|
+
* large (>= k vertices) tree in the forest of tight shortest-path edges. Only
|
|
7
|
+
* the pivots need to be carried into the deeper BMSSP recursion.
|
|
8
|
+
*
|
|
9
|
+
* Preconditions (guaranteed by the caller, Algorithm 3):
|
|
10
|
+
* - Every vertex in S is complete (dHat holds its true distance).
|
|
11
|
+
* - Every incomplete vertex v with d(v) < B has a shortest path through some
|
|
12
|
+
* complete vertex in S.
|
|
13
|
+
*
|
|
14
|
+
* Distance estimates are read from and written into dHat in place — exactly
|
|
15
|
+
* like the paper's global d̂[·] labels, so improvements made here are visible
|
|
16
|
+
* to the levels above. The `<=` relaxation updates d̂ unconditionally; the
|
|
17
|
+
* `< B` test only gates membership in W.
|
|
18
|
+
*
|
|
19
|
+
* Two outcomes:
|
|
20
|
+
* - Early exit (|W| grows past k·|S|): the frontier is already small relative
|
|
21
|
+
* to W, so every vertex of S is a pivot.
|
|
22
|
+
* - Forest case (k rounds complete with |W| <= k·|S|): pivots are the S-roots
|
|
23
|
+
* of tight-edge trees with >= k vertices. Equal-length paths can make the
|
|
24
|
+
* tight-edge graph a DAG (see #163); each vertex is assigned at most one
|
|
25
|
+
* parent deterministically (first tight edge in W iteration order) so tree
|
|
26
|
+
* sizes are well-defined. Guarantees |pivots| <= |W| / k either way.
|
|
27
|
+
*
|
|
28
|
+
* @param {number} B - Strict upper bound gating membership in W (Infinity is allowed)
|
|
29
|
+
* @param {Set<*>|Iterable<*>} S - Non-empty set of complete frontier sources
|
|
30
|
+
* @param {Map<*, number>} dHat - Global distance estimates d̂[·], updated in place
|
|
31
|
+
* @param {Map<*, Array<[*, number]>>} adjacency - nodeId -> outgoing [to, weight] edges
|
|
32
|
+
* @param {number} k - Relaxation rounds / tree-size threshold, >= 1 (floored);
|
|
33
|
+
* the paper's ⌊log^(1/3) n⌋
|
|
34
|
+
* @returns {{ pivots: Set<*>, W: Set<*> }} The pivot subset of S and the set W
|
|
35
|
+
* of vertices touched below B (W always contains S)
|
|
36
|
+
* @throws {Error} If k is not a number >= 1, S is empty, or any source has no
|
|
37
|
+
* finite distance estimate
|
|
38
|
+
*/
|
|
39
|
+
function findPivots(B, S, dHat, adjacency, k) {
|
|
40
|
+
if (typeof k !== "number" || Number.isNaN(k) || k < 1) {
|
|
41
|
+
throw new Error("k must be a number >= 1");
|
|
42
|
+
}
|
|
43
|
+
const rounds = Math.floor(k);
|
|
44
|
+
const sources = [...S];
|
|
45
|
+
if (sources.length === 0) {
|
|
46
|
+
throw new Error("S must contain at least one source node");
|
|
47
|
+
}
|
|
48
|
+
for (const x of sources) {
|
|
49
|
+
const distance = dHat.get(x);
|
|
50
|
+
if (typeof distance !== "number" || !Number.isFinite(distance)) {
|
|
51
|
+
throw new Error("every source must have a finite distance estimate");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// W accumulates everything relaxed below B; layer is the paper's W_{i-1}
|
|
56
|
+
const W = new Set(sources);
|
|
57
|
+
let layer = new Set(sources);
|
|
58
|
+
|
|
59
|
+
for (let i = 1; i <= rounds; i += 1) {
|
|
60
|
+
const nextLayer = new Set();
|
|
61
|
+
for (const u of layer) {
|
|
62
|
+
for (const [v, weight] of adjacency.get(u) ?? []) {
|
|
63
|
+
const candidate = dHat.get(u) + weight;
|
|
64
|
+
// Paper relaxation: <= always updates d̂; < B gates the W membership
|
|
65
|
+
if (candidate <= (dHat.get(v) ?? Infinity)) {
|
|
66
|
+
dHat.set(v, candidate);
|
|
67
|
+
if (candidate < B) nextLayer.add(v);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const v of nextLayer) W.add(v);
|
|
72
|
+
layer = nextLayer;
|
|
73
|
+
if (W.size > rounds * sources.length) {
|
|
74
|
+
// Frontier already small relative to W: every source is a pivot
|
|
75
|
+
return { pivots: new Set(sources), W };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Forest F of tight edges inside W. Each vertex takes at most one parent,
|
|
80
|
+
// chosen deterministically, so tree sizes stay well-defined even when ties
|
|
81
|
+
// make F a DAG. Vertices on tight cycles (zero-weight cycles) all end up
|
|
82
|
+
// with parents, so they belong to no root's tree.
|
|
83
|
+
const children = new Map();
|
|
84
|
+
const hasParent = new Set();
|
|
85
|
+
for (const u of W) {
|
|
86
|
+
const du = dHat.get(u);
|
|
87
|
+
for (const [v, weight] of adjacency.get(u) ?? []) {
|
|
88
|
+
if (v === u || !W.has(v) || hasParent.has(v)) continue;
|
|
89
|
+
if (dHat.get(v) === du + weight) {
|
|
90
|
+
hasParent.add(v);
|
|
91
|
+
if (!children.has(u)) children.set(u, []);
|
|
92
|
+
children.get(u).push(v);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Pivots: sources that root a tight-edge tree with >= k vertices
|
|
98
|
+
const pivots = new Set();
|
|
99
|
+
for (const x of sources) {
|
|
100
|
+
if (hasParent.has(x)) continue;
|
|
101
|
+
let size = 0;
|
|
102
|
+
const stack = [x];
|
|
103
|
+
while (stack.length > 0) {
|
|
104
|
+
const u = stack.pop();
|
|
105
|
+
size += 1;
|
|
106
|
+
for (const child of children.get(u) ?? []) stack.push(child);
|
|
107
|
+
}
|
|
108
|
+
if (size >= rounds) pivots.add(x);
|
|
109
|
+
}
|
|
110
|
+
return { pivots, W };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export { findPivots };
|