bmssp 1.1.1 → 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.
@@ -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,9 @@
1
- import { compareKeys, toBound, makeTies, relaxEdge } from "./tieBreak.mjs";
1
+ import {
2
+ compareKeyParts,
3
+ toBound,
4
+ relaxEdge,
5
+ RELAX_LOST,
6
+ } from "./tieBreak.mjs";
2
7
 
3
8
  /**
4
9
  * FindPivots(B, S) — Algorithm 1 of "Breaking the Sorting Barrier for Directed
@@ -9,17 +14,19 @@ import { compareKeys, toBound, makeTies, relaxEdge } from "./tieBreak.mjs";
9
14
  * the pivots need to be carried into the deeper BMSSP recursion.
10
15
  *
11
16
  * Preconditions (guaranteed by the caller, Algorithm 3):
12
- * - Every vertex in S is complete (dHat holds its true distance).
17
+ * - Every vertex in S is complete (labels.dist holds its true distance).
13
18
  * - Every incomplete vertex v with d(v) < B has a shortest path through some
14
19
  * complete vertex in S.
15
20
  *
16
- * Distance estimates are read from and written into dHat in place exactly
17
- * like the paper's global d̂[·] labels, so improvements made here are visible
18
- * to the levels above. Relaxation is canonical (#163, src/tieBreak.mjs):
19
- * d̂, hops and preds move together under the composite [length, hops, id]
20
- * order, and an exact-equality result re-admits a vertex to W through its
21
- * one canonical label-setter (the paper's `≤` made deterministic). The `< B`
22
- * 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.
23
30
  *
24
31
  * The paper's tight-edge forest falls out of the canonical labels: each
25
32
  * vertex of W \ S hangs off its recorded canonical predecessor, which is
@@ -38,19 +45,20 @@ import { compareKeys, toBound, makeTies, relaxEdge } from "./tieBreak.mjs";
38
45
  *
39
46
  * @param {number|[number, number, *]} B - Strict upper bound gating membership
40
47
  * in W: a number (Infinity is allowed) or a composite bound
41
- * @param {Set<*>|Iterable<*>} S - Non-empty set of complete frontier sources
42
- * @param {Map<*, number>} dHat - Global distance estimates d̂[·], updated in place
43
- * @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
44
54
  * @param {number} k - Relaxation rounds / tree-size threshold, >= 1 (floored);
45
55
  * the paper's ⌊log^(1/3) n⌋
46
- * @param {{ hops: Map<*, number>, preds: Map<*, *> }} [ties] - Canonical
47
- * tie-break labels updated alongside dHat; fresh throwaway maps by default
48
- * @returns {{ pivots: Set<*>, W: Set<*> }} The pivot subset of S and the set W
49
- * 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)
50
58
  * @throws {Error} If k is not a number >= 1, S is empty, or any source has no
51
59
  * finite distance estimate
52
60
  */
53
- function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
61
+ function findPivots(B, S, labels, csr, k) {
54
62
  if (typeof k !== "number" || Number.isNaN(k) || k < 1) {
55
63
  throw new Error("k must be a number >= 1");
56
64
  }
@@ -60,12 +68,12 @@ function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
60
68
  throw new Error("S must contain at least one source node");
61
69
  }
62
70
  for (const x of sources) {
63
- const distance = dHat.get(x);
64
- if (typeof distance !== "number" || !Number.isFinite(distance)) {
71
+ if (!Number.isFinite(labels.dist[x])) {
65
72
  throw new Error("every source must have a finite distance estimate");
66
73
  }
67
74
  }
68
75
  const boundKey = toBound(B);
76
+ const { offsets, targets, weights } = csr;
69
77
 
70
78
  // W accumulates everything relaxed below B; layer is the paper's W_{i-1}
71
79
  const sourceSet = new Set(sources);
@@ -75,12 +83,18 @@ function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
75
83
  for (let i = 1; i <= rounds; i += 1) {
76
84
  const nextLayer = new Set();
77
85
  for (const u of layer) {
78
- for (const [v, weight] of adjacency.get(u) ?? []) {
79
- // Canonical relaxation: d̂ updates are not gated by B (paper), and
80
- // 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) {
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
90
+ // next layer when its key is below B. Non-lost ⇒ v's stored label
91
+ // is the candidate, so the W gate compares the unpacked stored
92
+ // components — no key allocation (#168).
93
+ const result = relaxEdge(u, v, weights[e], labels);
94
+ if (
95
+ result !== RELAX_LOST &&
96
+ compareKeyParts(labels.dist[v], labels.hops[v], v, boundKey) < 0
97
+ ) {
84
98
  nextLayer.add(v);
85
99
  }
86
100
  }
@@ -99,7 +113,7 @@ function findPivots(B, S, dHat, adjacency, k, ties = makeTies()) {
99
113
  const children = new Map();
100
114
  for (const v of W) {
101
115
  if (sourceSet.has(v)) continue;
102
- const parent = ties.preds.get(v);
116
+ const parent = labels.preds[v];
103
117
  if (!children.has(parent)) children.set(parent, []);
104
118
  children.get(parent).push(v);
105
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/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 };