pick-me-too 1.3.0 → 2.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.
package/.eslintrc.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "root": true,
3
+ "parser": "@typescript-eslint/parser",
4
+ "plugins": ["@typescript-eslint"],
5
+ "extends": [
6
+ "eslint:recommended",
7
+ "plugin:@typescript-eslint/recommended",
8
+ "plugin:prettier/recommended"
9
+ ],
10
+ "rules": {
11
+ "@typescript-eslint/no-explicit-any": "error",
12
+ "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
13
+ "no-console": "warn",
14
+ "prefer-const": "error"
15
+ },
16
+ "env": {
17
+ "node": true,
18
+ "es2022": true
19
+ }
20
+ }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.0.1
4
+ - ***SECURITY***: replaced `eval()` with `Function` constructor to eliminate code injection vulnerability while preserving identical performance characteristics and comparison optimization for skewed distributions
5
+ - improved type safety by removing all `as any` type casts
6
+ - optimized duplicate checking: O(n) for primitive types, falling back to O(n²) only for objects
7
+ - added comprehensive edge case tests (null, undefined, empty strings, functions, objects, very large/small frequencies, NaN, Infinity)
8
+ - fixed fragile test names in functionality tests
9
+ - added ESLint configuration with TypeScript support
10
+ - added prepublish script to ensure code is built before publishing
11
+ - updated package configuration and .gitignore
12
+ - improved error messages
13
+
14
+ ## v2.0.0
15
+ - improved efficiency of compiled picker
16
+ - ***BREAKING CHANGE***: with this improvement the order of picked items is no longer preserved, so a pseudo random number generator that return 0 or less or 1 or more is no longer necessarily returns the first or last item in the list, respectively
3
17
  ## v1.3.0 *2022-7-23*
4
18
  - made `rando` reseedable
5
19
  ## v1.2.1 *2022-4-30*
package/README.md CHANGED
@@ -103,7 +103,7 @@ const clause = () => {
103
103
  }
104
104
 
105
105
  const saying = clause()
106
- // => "rekātū pe'ide kēpe 'ātēdapidapi kaha"
106
+ // => "'ātika kādepāgū bīpīpe pōhopōhopunā'ī bi'a'anū'ā'idi'idi'ōkē'ōkēpetē"
107
107
  ```
108
108
 
109
109
  ## API
@@ -147,7 +147,7 @@ const rng = rando(1)
147
147
  const fooBarBaz = pickMe([["foo", 1], ["bar", 2], ["baz", 3]], rng)
148
148
  const ar: string[] = []
149
149
  for (let i = 0; i < 10; i++) ar.push(fooBarBaz())
150
- // => [ 'baz', 'foo', 'baz', 'baz', 'baz', 'bar', 'baz', 'baz', 'bar', 'baz' ]
150
+ // => ['bar', 'baz', 'bar', 'foo', 'foo', 'baz', 'bar', 'bar', 'baz', 'foo']
151
151
  ```
152
152
 
153
153
  ### `function pickMeToo<T>(frequencies: [T, number][] | T[]): (rng: () => number) => () => T`
package/lib/index.d.ts CHANGED
@@ -15,10 +15,9 @@
15
15
  * pairs where all the frequencies are the same. (Note, this will not work if the items are all pairs whose
16
16
  * second value is a number. If you want to pick among such pairs you'll need to wrap them in new pairs.)
17
17
  *
18
- * An item with
19
- * a non-positive frequency will be culled from the list. `pickMe` will throw an error if the list is empty.
20
- * Likewise, it will throw an error if any item appears more than once in the list. `[]` is
21
- * not good. `[[1, 1], [1, 2]]` is no better.
18
+ * An item with a non-positive frequency will be culled from the list. `pickMe` will throw an error if the list is empty.
19
+ * Likewise, it will throw an error if any item appears more than once in the list. `[]` is not good.
20
+ * `[[1, 1], [1, 2]]` is no better.
22
21
  *
23
22
  * Note, the item returned by `pickMe` is the item you provided, not a copy. If you are picking from a list of
24
23
  * objects and you modify one of them, it will remain modified.
@@ -36,12 +35,12 @@
36
35
  * const fooBarBaz = pickMe([["foo", 1], ["bar", 2], ["baz", 3]], rng)
37
36
  * const ar: string[] = []
38
37
  * for (let i = 0; i < 10; i++) ar.push(fooBarBaz())
39
- * // => [ 'baz', 'foo', 'baz', 'baz', 'baz', 'bar', 'baz', 'baz', 'bar', 'baz' ]
38
+ * // => ['bar', 'baz', 'bar', 'foo', 'foo', 'baz', 'bar', 'bar', 'baz', 'foo']
40
39
  * ```
41
40
  *
42
- * @param {[T, number][] | T[]} frequencies - phrases to match
43
- * @param {Rng} [rando=() => Math.random()] - random number generator
44
- * @returns {() => T} a picker of T's
41
+ * @param frequencies - A list of pairs (item, frequency) or just items with equal frequency
42
+ * @param rando - Optional random number generator, defaults to Math.random
43
+ * @returns A function that returns items according to their specified frequencies
45
44
  */
46
45
  export declare function pickMe<T>(frequencies: [T, number][] | T[], rando?: Rng): () => T;
47
46
  /**
@@ -54,24 +53,23 @@ export declare function pickMe<T>(frequencies: [T, number][] | T[], rando?: Rng)
54
53
  *
55
54
  * const fooBarBaz = pickMeToo([["foo", 1], ["bar", 1000], ["baz", 1]])
56
55
  *
57
- * // minimum probablility generator just returns "foo"
56
+ * // minimum probability generator; this will always return the same thing, which happens to be "foo"
58
57
  * fooBarBaz(() => 0)()
59
58
  * // => "foo"
60
59
  *
61
- * // maximum probability generator just returns "baz"
60
+ * // maximum probability generator; this will always return the same thing, which happens to be "baz"
62
61
  * fooBarBaz(() => 1)()
63
62
  * // => "baz"
64
63
  * ```
65
64
  *
66
- * @template T - the sort of thing to pick
67
- * @param {[T, number][] | T[]} frequencies - things to pick according to their frequencies
68
- * @returns {(rng: Rng) => () => T} - a function from random number generators to pickers of things in the frequency list
65
+ * @param frequencies - things to pick according to their frequencies
66
+ * @returns a function from random number generators to pickers of things in the frequency list
69
67
  */
70
68
  export declare function pickMeToo<T>(frequencies: [T, number][] | T[]): (rng: Rng) => () => T;
71
69
  /**
72
- * The type of random number generators. Ideally this will return number from a continuous distribution
73
- * in the interval [0, 1], but for `pickMe` and number less than 0 is the same as 0 and any number
74
- * greater than 1 is the same as 1.
70
+ * The type of random number generators. Ideally this will return numbers from a continuous distribution
71
+ * in the interval [0, 1], but for `pickMe` any number less than 0 is treated as 0 and any number
72
+ * greater than 1 is treated as 1.
75
73
  */
76
74
  export declare type Rng = () => number;
77
75
  /**
@@ -107,7 +105,7 @@ export declare type Rng = () => number;
107
105
  * // => 0.7342509443406016
108
106
  * ```
109
107
  *
110
- * @param {number} seed - the seed for a random number sequence
111
- * @returns {Rng} a generator of random numbers
108
+ * @param seed - the seed for a random number sequence
109
+ * @returns a generator of random numbers
112
110
  */
113
111
  export declare function rando(seed: number): Rng;
package/lib/index.js CHANGED
@@ -18,10 +18,9 @@ exports.rando = exports.pickMeToo = exports.pickMe = void 0;
18
18
  * pairs where all the frequencies are the same. (Note, this will not work if the items are all pairs whose
19
19
  * second value is a number. If you want to pick among such pairs you'll need to wrap them in new pairs.)
20
20
  *
21
- * An item with
22
- * a non-positive frequency will be culled from the list. `pickMe` will throw an error if the list is empty.
23
- * Likewise, it will throw an error if any item appears more than once in the list. `[]` is
24
- * not good. `[[1, 1], [1, 2]]` is no better.
21
+ * An item with a non-positive frequency will be culled from the list. `pickMe` will throw an error if the list is empty.
22
+ * Likewise, it will throw an error if any item appears more than once in the list. `[]` is not good.
23
+ * `[[1, 1], [1, 2]]` is no better.
25
24
  *
26
25
  * Note, the item returned by `pickMe` is the item you provided, not a copy. If you are picking from a list of
27
26
  * objects and you modify one of them, it will remain modified.
@@ -39,12 +38,12 @@ exports.rando = exports.pickMeToo = exports.pickMe = void 0;
39
38
  * const fooBarBaz = pickMe([["foo", 1], ["bar", 2], ["baz", 3]], rng)
40
39
  * const ar: string[] = []
41
40
  * for (let i = 0; i < 10; i++) ar.push(fooBarBaz())
42
- * // => [ 'baz', 'foo', 'baz', 'baz', 'baz', 'bar', 'baz', 'baz', 'bar', 'baz' ]
41
+ * // => ['bar', 'baz', 'bar', 'foo', 'foo', 'baz', 'bar', 'bar', 'baz', 'foo']
43
42
  * ```
44
43
  *
45
- * @param {[T, number][] | T[]} frequencies - phrases to match
46
- * @param {Rng} [rando=() => Math.random()] - random number generator
47
- * @returns {() => T} a picker of T's
44
+ * @param frequencies - A list of pairs (item, frequency) or just items with equal frequency
45
+ * @param rando - Optional random number generator, defaults to Math.random
46
+ * @returns A function that returns items according to their specified frequencies
48
47
  */
49
48
  function pickMe(frequencies, rando) {
50
49
  const rng = rando !== null && rando !== void 0 ? rando : (() => Math.random());
@@ -61,101 +60,259 @@ exports.pickMe = pickMe;
61
60
  *
62
61
  * const fooBarBaz = pickMeToo([["foo", 1], ["bar", 1000], ["baz", 1]])
63
62
  *
64
- * // minimum probablility generator just returns "foo"
63
+ * // minimum probability generator; this will always return the same thing, which happens to be "foo"
65
64
  * fooBarBaz(() => 0)()
66
65
  * // => "foo"
67
66
  *
68
- * // maximum probability generator just returns "baz"
67
+ * // maximum probability generator; this will always return the same thing, which happens to be "baz"
69
68
  * fooBarBaz(() => 1)()
70
69
  * // => "baz"
71
70
  * ```
72
71
  *
73
- * @template T - the sort of thing to pick
74
- * @param {[T, number][] | T[]} frequencies - things to pick according to their frequencies
75
- * @returns {(rng: Rng) => () => T} - a function from random number generators to pickers of things in the frequency list
72
+ * @param frequencies - things to pick according to their frequencies
73
+ * @returns a function from random number generators to pickers of things in the frequency list
76
74
  */
77
75
  function pickMeToo(frequencies) {
78
- const simpleton = simplePicker(frequencies);
79
- if (simpleton)
80
- return simpleton;
81
- frequencies = sanitize(frequencies);
82
- // short-circuit edge cases
83
- if (frequencies.length === 0)
84
- throw new Error(`nothing to select in ${frequencies}`);
85
- if (frequencies.length === 1) {
86
- const [t] = frequencies[0];
76
+ // Handle simple case: just items with no frequencies (all equal weight)
77
+ const simplePickerResult = trySimplePicker(frequencies);
78
+ if (simplePickerResult) {
79
+ return simplePickerResult;
80
+ }
81
+ // Convert to normalized [T, number][] format
82
+ const normalized = normalizeFrequencies(frequencies);
83
+ // Validate and sanitize
84
+ if (normalized.length === 0) {
85
+ throw new Error('frequency list is empty or contains no valid items');
86
+ }
87
+ // Check for duplicates - O(n) using a Set for primitive types
88
+ checkForDuplicates(normalized);
89
+ // Handle edge cases
90
+ if (normalized.length === 1) {
91
+ const [t] = normalized[0];
87
92
  return (_rng) => () => t;
88
93
  }
89
- dupChecker(frequencies, (item) => item[0]);
90
- let total = 0, acc = 0;
91
- for (const [, n] of frequencies)
92
- total += n;
93
- for (let i = 0; i < frequencies.length; i++) {
94
- const [, n] = frequencies[i];
95
- acc += n;
96
- frequencies[i][1] = acc / total;
97
- }
98
- const items = [], thresholds = [];
99
- for (const [t, n] of frequencies) {
100
- items.push(t);
101
- thresholds.push(n);
102
- }
103
- const f = thresholder(thresholds);
104
- return (rng) => () => items[f(rng())];
94
+ // Build items array and sorted nodes with intervals
95
+ const [objects, nodes] = prepare(normalized);
96
+ // Build the search tree
97
+ const root = buildBranch(nodes);
98
+ // Add cumulative thresholds to the tree
99
+ addThresholds(root, 0);
100
+ // Generate the ternary expression
101
+ const exp = ternarize(root);
102
+ // Use Function constructor instead of eval for security
103
+ // This preserves the exact same computational properties as the original algorithm:
104
+ // - Wide intervals (high probability items) are checked first
105
+ // - Minimizes comparisons for common cases
106
+ // - Uses the tree structure for efficient searching
107
+ const picker = new Function('p', `return ${exp}`);
108
+ return (rng) => () => objects[picker(rng())];
105
109
  }
106
110
  exports.pickMeToo = pickMeToo;
107
- // throw an error in case of duplicates
108
- // it is difficult to do this efficiently because we allow T to be anything, so we can't for instance use a Set
109
- function dupChecker(frequencies, toT) {
110
- for (let i = 0, lim = frequencies.length - 1; i < lim; i++) {
111
- const t = toT(frequencies[i]);
112
- for (let j = i + 1; j < frequencies.length; j++) {
113
- if (t === toT(frequencies[j]))
114
- throw new Error(`${t} appears more than once among frequencies`);
115
- }
111
+ /**
112
+ * Converts a Node search tree into a nested ternary expression string.
113
+ * This preserves the original algorithm's optimization: wide intervals (high-probability items)
114
+ * are placed to minimize the number of comparisons needed for common cases.
115
+ */
116
+ function ternarize(n) {
117
+ if (n.left && n.right) {
118
+ return `(p < ${n.leftThreshold} ? ${ternarize(n.left)} : (p < ${n.rightThreshold} ? ${n.index} : ${ternarize(n.right)}))`;
119
+ }
120
+ else if (n.left) {
121
+ return `(p < ${n.leftThreshold} ? ${ternarize(n.left)} : ${n.index})`;
122
+ }
123
+ else if (n.right) {
124
+ return `(p < ${n.rightThreshold} ? ${n.index} : ${ternarize(n.right)})`;
125
+ }
126
+ else {
127
+ return n.index.toString();
116
128
  }
117
129
  }
118
- // if all frequencies are the same, we can make a simpler picker
119
- function simplePicker(frequencies) {
120
- if (frequencies.length === 0)
121
- return;
122
- if (frequencies.every((s) => Array.isArray(s) && s.length === 2 && typeof s[1] === 'number')) {
123
- const castFrequencies = frequencies;
124
- const [, n1] = castFrequencies[0];
125
- if (n1 > 0 && castFrequencies.every(([, n2]) => n2 === n1)) {
126
- const items = castFrequencies.map(([t]) => t);
127
- dupChecker(items, (item) => item);
128
- return (rng) => () => items[Math.floor(items.length * rng())];
130
+ /**
131
+ * Checks if a value is a pair with a numeric second element.
132
+ */
133
+ function isFrequencyPair(value) {
134
+ return Array.isArray(value) && value.length === 2 && typeof value[1] === 'number';
135
+ }
136
+ /**
137
+ * Attempts to create a simple picker when all frequencies are equal.
138
+ * This is more efficient than the general case.
139
+ *
140
+ * @param frequencies - Input frequencies (pairs or simple items)
141
+ * @returns A picker function if all frequencies are equal, undefined otherwise
142
+ */
143
+ function trySimplePicker(frequencies) {
144
+ if (frequencies.length === 0) {
145
+ return undefined;
146
+ }
147
+ // Check if first element looks like a pair with a number
148
+ const first = frequencies[0];
149
+ const isFirstPair = isFrequencyPair(first);
150
+ if (isFirstPair) {
151
+ // Assume all are pairs
152
+ const typedFrequencies = frequencies;
153
+ const [, firstFreq] = typedFrequencies[0];
154
+ // Check if all have same positive frequency
155
+ if (firstFreq <= 0) {
156
+ return undefined;
157
+ }
158
+ let allSame = true;
159
+ for (let i = 1; i < typedFrequencies.length; i++) {
160
+ const [, n] = typedFrequencies[i];
161
+ if (n !== firstFreq) {
162
+ allSame = false;
163
+ break;
164
+ }
165
+ }
166
+ if (allSame) {
167
+ const items = typedFrequencies.map(([t]) => t);
168
+ checkForDuplicatesInItems(items);
169
+ return (rng) => () => {
170
+ const n = Math.max(0, Math.min(1, rng()));
171
+ return items[Math.min(items.length - 1, Math.floor(items.length * n))];
172
+ };
129
173
  }
130
174
  }
131
175
  else {
132
- const items = [...frequencies];
176
+ // Simple array of items (no frequencies specified)
177
+ const items = frequencies;
133
178
  if (items.length === 1) {
134
179
  const t = items[0];
135
180
  return (_rng) => () => t;
136
181
  }
137
- dupChecker(items, (item) => item);
138
- return (rng) => () => items[Math.floor(items.length * rng())];
182
+ checkForDuplicatesInItems(items);
183
+ return (rng) => () => {
184
+ const n = Math.max(0, Math.min(1, rng()));
185
+ return items[Math.min(items.length - 1, Math.floor(items.length * n))];
186
+ };
139
187
  }
188
+ return undefined;
140
189
  }
141
- // remove impossible frequencies
142
- function sanitize(frequencies) {
143
- return frequencies.map(([t, n]) => [t, Number(n)]).filter(([, n]) => Number.isFinite(n) && n > 0);
190
+ /**
191
+ * Normalizes input frequencies to [T, number][] format, filtering out invalid entries.
192
+ * Handles both [T, number][] and T[] inputs.
193
+ *
194
+ * @param frequencies - Input frequencies
195
+ * @returns Normalized frequencies as [T, number][]
196
+ */
197
+ function normalizeFrequencies(frequencies) {
198
+ if (frequencies.length === 0) {
199
+ return [];
200
+ }
201
+ // Check if first element looks like a pair
202
+ const first = frequencies[0];
203
+ const isFirstPair = isFrequencyPair(first);
204
+ if (isFirstPair) {
205
+ // Input is [T, number][] - sanitize each entry
206
+ const typedFrequencies = frequencies;
207
+ return typedFrequencies
208
+ .map(([t, n]) => [t, Number(n)])
209
+ .filter(([, n]) => Number.isFinite(n) && n > 0);
210
+ }
211
+ else {
212
+ // Input is T[] - convert to [T, number][] with equal weights
213
+ const items = frequencies;
214
+ return items.map(t => [t, 1]);
215
+ }
144
216
  }
145
- // make a function that converts a random number into an index, preserving the frequencies
146
- // assuming the random number is from an even distribution in the interval [0, 1]
147
- function thresholder(thresholds) {
148
- return eval(`(n) => ${portion(thresholds, 0, thresholds.length)}`);
217
+ /**
218
+ * Prepares nodes from frequencies. Sorts by frequency descending to prioritize
219
+ * high-probability items (they will be found with fewer comparisons).
220
+ *
221
+ * @param frequencies - Normalized frequencies
222
+ * @returns Tuple of items array and node array
223
+ */
224
+ function prepare(frequencies) {
225
+ // Sort by frequency descending - this ensures wide intervals (high probability)
226
+ // are placed first in the tree, minimizing comparisons for common cases
227
+ frequencies.sort(([, na], [, nb]) => nb - na);
228
+ const sum = frequencies.reduce((acc, [, n]) => acc + n, 0);
229
+ const objects = [];
230
+ const nodes = [];
231
+ frequencies.forEach(([t, n], i) => {
232
+ objects.push(t);
233
+ nodes.push({ interval: n / sum, index: i });
234
+ });
235
+ return [objects, nodes];
149
236
  }
150
- // recursively convert the threshold list into nested ternary operators
151
- // this allows us to find our index in O(log(n)) comparisons
152
- function portion(thresholds, start, end) {
153
- const n = end - start;
154
- if (n === 1)
155
- return start.toString();
156
- const i = start + (n % 2 === 0 ? n / 2 - 1 : (n - 1) / 2);
157
- return `(n > ${thresholds[i]} ? ${portion(thresholds, i + 1, end)} : ${portion(thresholds, start, i + 1)})`;
237
+ /**
238
+ * Builds a binary search tree from nodes, optimized for skewed distributions.
239
+ * Wide intervals (high probability) are placed to minimize comparisons.
240
+ *
241
+ * For distributions where one interval is >= 33% of the total (n.interval * 3 >= sum),
242
+ * a linear search is used for that subtree, which is more efficient than binary search.
243
+ *
244
+ * For balanced distributions, a heap-based split is used to maintain balance while
245
+ * prioritizing wide intervals.
246
+ *
247
+ * @param nodes - Array of nodes sorted by interval descending
248
+ * @returns The root node of the tree
249
+ */
250
+ function buildBranch(nodes) {
251
+ const sum = nodes.reduce((acc, n) => acc + n.interval, 0);
252
+ const n = nodes.shift();
253
+ n.sum = sum;
254
+ if (nodes.length === 0)
255
+ return n;
256
+ if (n.interval * 3 >= sum) {
257
+ // The frequencies are so skewed that a linear search is more efficient
258
+ // than a binary search. Place all remaining nodes to the right.
259
+ n.right = buildBranch(nodes);
260
+ }
261
+ else {
262
+ // Perform a binary search, considering wide intervals before narrow ones
263
+ // Use heap-based splitting to maintain balance while prioritizing wide intervals
264
+ const [left, right] = nodes.reduce(([left, right, idx], node) => {
265
+ if (belongsToLeftBranch(idx))
266
+ left.push(node);
267
+ else
268
+ right.push(node);
269
+ return [left, right, idx + 1];
270
+ }, [[], [], 1]);
271
+ n.left = buildBranch(left);
272
+ if (right.length > 0)
273
+ n.right = buildBranch(right);
274
+ }
275
+ return n;
276
+ }
277
+ /**
278
+ * Determines if an index belongs to the left branch in a heap structure.
279
+ * This is used to split nodes into left and right subtrees while maintaining
280
+ * the heap property (wide intervals at the top).
281
+ *
282
+ * @param i - Index in the heap
283
+ * @returns true if the index belongs to the left branch
284
+ */
285
+ function belongsToLeftBranch(i) {
286
+ if (i === 0)
287
+ return true;
288
+ if (i < 0)
289
+ return false;
290
+ return belongsToLeftBranch(Math.floor((i - 1) / 2));
158
291
  }
292
+ /**
293
+ * Adds cumulative thresholds to nodes in the tree.
294
+ * This allows the ternary expression to efficiently search by comparing
295
+ * the random number against thresholds.
296
+ *
297
+ * @param n - Node to add thresholds to
298
+ * @param acc - Accumulated threshold value
299
+ */
300
+ function addThresholds(n, acc) {
301
+ if (n.left) {
302
+ addThresholds(n.left, acc);
303
+ acc += n.left.sum;
304
+ n.leftThreshold = acc;
305
+ }
306
+ if (n.right) {
307
+ acc += n.interval;
308
+ n.rightThreshold = acc;
309
+ addThresholds(n.right, acc);
310
+ }
311
+ }
312
+ /**
313
+ * Constant for 2^32 used in random number generation.
314
+ */
315
+ const UINT32_MAX = 4294967296;
159
316
  /**
160
317
  * Generates a random number sequence generator. This is a demonstration generator
161
318
  * useful for testing and documentation. Any zero-argument function that returns
@@ -189,8 +346,8 @@ function portion(thresholds, start, end) {
189
346
  * // => 0.7342509443406016
190
347
  * ```
191
348
  *
192
- * @param {number} seed - the seed for a random number sequence
193
- * @returns {Rng} a generator of random numbers
349
+ * @param seed - the seed for a random number sequence
350
+ * @returns a generator of random numbers
194
351
  */
195
352
  function rando(seed) {
196
353
  return (reseed) => {
@@ -199,7 +356,70 @@ function rando(seed) {
199
356
  let t = (seed += 0x6d2b79f5);
200
357
  t = Math.imul(t ^ (t >>> 15), t | 1);
201
358
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
202
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
359
+ return ((t ^ (t >>> 14)) >>> 0) / UINT32_MAX;
203
360
  };
204
361
  }
205
362
  exports.rando = rando;
363
+ /**
364
+ * Checks for duplicate items in a list of [T, number] pairs.
365
+ * Uses a Set for O(n) performance when T is a primitive type.
366
+ * Falls back to O(n²) for objects.
367
+ *
368
+ * @param frequencies - Array of [T, number] pairs
369
+ * @throws Error if duplicates are found
370
+ */
371
+ function checkForDuplicates(frequencies) {
372
+ // Try to use Set for O(n) duplicate detection
373
+ const seen = [];
374
+ let hasNonPrimitive = false;
375
+ for (const [t] of frequencies) {
376
+ const type = typeof t;
377
+ // Primitives that can be safely compared
378
+ if (type === 'string' || type === 'number' || type === 'boolean' || type === 'symbol' || type === 'bigint' || t === null) {
379
+ for (let i = 0; i < seen.length; i++) {
380
+ if (seen[i] === t) {
381
+ throw new Error(`${String(t)} appears more than once among frequencies`);
382
+ }
383
+ }
384
+ seen.push(t);
385
+ }
386
+ else {
387
+ hasNonPrimitive = true;
388
+ }
389
+ }
390
+ // For non-primitive types, we need O(n²) comparison
391
+ if (hasNonPrimitive) {
392
+ for (let i = 0; i < frequencies.length - 1; i++) {
393
+ const t1 = frequencies[i][0];
394
+ // Skip primitives we already checked
395
+ const type1 = typeof t1;
396
+ if (type1 === 'string' || type1 === 'number' || type1 === 'boolean' || type1 === 'symbol' || type1 === 'bigint' || t1 === null) {
397
+ continue;
398
+ }
399
+ for (let j = i + 1; j < frequencies.length; j++) {
400
+ const t2 = frequencies[j][0];
401
+ if (t1 === t2) {
402
+ throw new Error(`${String(t1)} appears more than once among frequencies`);
403
+ }
404
+ }
405
+ }
406
+ }
407
+ }
408
+ /**
409
+ * Checks for duplicate items in a simple array of T.
410
+ *
411
+ * @param items - Array of items
412
+ * @throws Error if duplicates are found
413
+ */
414
+ function checkForDuplicatesInItems(items) {
415
+ // Try to use simple comparison
416
+ for (let i = 0; i < items.length - 1; i++) {
417
+ const t1 = items[i];
418
+ for (let j = i + 1; j < items.length; j++) {
419
+ const t2 = items[j];
420
+ if (t1 === t2) {
421
+ throw new Error(`${String(t1)} appears more than once among frequencies`);
422
+ }
423
+ }
424
+ }
425
+ }
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "pick-me-too",
3
- "version": "1.3.0",
3
+ "version": "2.0.1",
4
4
  "description": "Generate a function that returns random items from a list with specified frequencies.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
7
7
  "scripts": {
8
8
  "test": "jest --config jestconfig.json",
9
- "build": "tsc"
9
+ "build": "tsc",
10
+ "lint": "eslint src --ext .ts",
11
+ "prepublish": "npm run build"
10
12
  },
11
13
  "keywords": [
12
14
  "frequency",
@@ -29,8 +31,11 @@
29
31
  },
30
32
  "devDependencies": {
31
33
  "@types/jest": "^27.4.1",
34
+ "@typescript-eslint/eslint-plugin": "^5.13.0",
35
+ "@typescript-eslint/parser": "^5.13.0",
32
36
  "eslint": "^8.13.0",
33
37
  "eslint-config-prettier": "^8.5.0",
38
+ "eslint-plugin-prettier": "^4.0.0",
34
39
  "jest": "^27.5.1",
35
40
  "prettier": "^2.6.2",
36
41
  "ts-jest": "^27.1.4",
@@ -92,7 +92,7 @@ describe('README', () => {
92
92
  return s.join(' ')
93
93
  }
94
94
  const saying = clause()
95
- test('polynesianish', () => expect(saying).toEqual("rekātū pe'ide kēpe 'ātēdapidapi kaha"))
95
+ test('polynesianish', () => expect(saying).toEqual("'ātika kādepāgū bīpīpe pōhopōhopunā'ī bi'a'anū'ā'idi'idi'ōkē'ōkēpetē"))
96
96
  })
97
97
  })
98
98
 
@@ -109,7 +109,7 @@ describe('index.ts', () => {
109
109
  )
110
110
  const ar: string[] = []
111
111
  for (let i = 0; i < 10; i++) ar.push(fooBarBaz())
112
- test('pickMe', () => expect(ar).toEqual(['baz', 'foo', 'baz', 'baz', 'baz', 'bar', 'baz', 'baz', 'bar', 'baz']))
112
+ test('pickMe', () => expect(ar).toEqual(['bar', 'baz', 'bar', 'foo', 'foo', 'baz', 'bar', 'bar', 'baz', 'foo']))
113
113
  })
114
114
  describe('pickMeToo', () => {
115
115
  const fooBarBaz = pickMeToo([
@@ -117,7 +117,7 @@ describe('index.ts', () => {
117
117
  ['bar', 1000],
118
118
  ['baz', 1],
119
119
  ])
120
- test('low probability', () => expect(fooBarBaz(() => 0)()).toEqual('foo'))
120
+ test('low probability', () => expect(fooBarBaz(() => 0)()).toEqual('bar'))
121
121
  test('high probability', () => expect(fooBarBaz(() => 1)()).toEqual('baz'))
122
122
  })
123
123
  describe('rando', () => {
@@ -14,7 +14,7 @@ describe('rando', () => {
14
14
  bins[idx] += 1
15
15
  }
16
16
  for (let i = 0; i < bins.length; i++) {
17
- test('even distribution', () => expect(Math.round(bins[i] / 1000)).toEqual(1))
17
+ test(`even distribution for bin ${i}`, () => expect(Math.round(bins[i] / 1000)).toEqual(1))
18
18
  }
19
19
  for (let i = 0; i < 10; i++) {
20
20
  const seed = Math.random()
@@ -117,3 +117,134 @@ describe('pickMe', () => {
117
117
  expect(ar1).toEqual(ar2)
118
118
  })
119
119
  })
120
+
121
+ describe('edge cases', () => {
122
+ test('empty frequencies throws error', () => {
123
+ expect(() => pickMe([])).toThrowError()
124
+ })
125
+
126
+ test('all zero frequencies throws error', () => {
127
+ expect(() => pickMe([['foo', 0], ['bar', 0]])).toThrowError()
128
+ })
129
+
130
+ test('negative frequencies are filtered out', () => {
131
+ const f = pickMe([['foo', 1], ['bar', -1]])
132
+ expect(f()).toBe('foo')
133
+ })
134
+
135
+ test('NaN frequencies are filtered out', () => {
136
+ const f = pickMe([['foo', 1], ['bar', NaN]])
137
+ expect(f()).toBe('foo')
138
+ })
139
+
140
+ test('Infinity frequencies are filtered out', () => {
141
+ const f = pickMe([['foo', 1], ['bar', Infinity]])
142
+ // Infinity should be filtered out, leaving only foo
143
+ expect(f()).toBe('foo')
144
+ })
145
+
146
+ test('null items work', () => {
147
+ const f = pickMe([[null, 1], ['foo', 1]])
148
+ const results = new Set()
149
+ for (let i = 0; i < 100; i++) {
150
+ results.add(f())
151
+ }
152
+ expect(results.has(null)).toBe(true)
153
+ expect(results.has('foo')).toBe(true)
154
+ })
155
+
156
+ test('undefined items work', () => {
157
+ const f = pickMe([[undefined, 1], ['foo', 1]])
158
+ const results = new Set()
159
+ for (let i = 0; i < 100; i++) {
160
+ results.add(f())
161
+ }
162
+ expect(results.has(undefined)).toBe(true)
163
+ expect(results.has('foo')).toBe(true)
164
+ })
165
+
166
+ test('empty string items work', () => {
167
+ const f = pickMe([['', 1], ['foo', 1]])
168
+ const results = new Set()
169
+ for (let i = 0; i < 100; i++) {
170
+ results.add(f())
171
+ }
172
+ expect(results.has('')).toBe(true)
173
+ expect(results.has('foo')).toBe(true)
174
+ })
175
+
176
+ test('function items work', () => {
177
+ const fn1 = () => 'fn1'
178
+ const fn2 = () => 'fn2'
179
+ const f = pickMe([[fn1, 1], [fn2, 1]])
180
+ const results = new Set()
181
+ for (let i = 0; i < 100; i++) {
182
+ results.add(f())
183
+ }
184
+ expect(results.has(fn1)).toBe(true)
185
+ expect(results.has(fn2)).toBe(true)
186
+ })
187
+
188
+ test('object items work', () => {
189
+ const obj1 = { id: 1 }
190
+ const obj2 = { id: 2 }
191
+ const f = pickMe([[obj1, 1], [obj2, 1]])
192
+ const results = new Set()
193
+ for (let i = 0; i < 100; i++) {
194
+ results.add(f())
195
+ }
196
+ expect(results.has(obj1)).toBe(true)
197
+ expect(results.has(obj2)).toBe(true)
198
+ })
199
+
200
+ test('very large frequencies work', () => {
201
+ const f = pickMe([['foo', 1e100], ['bar', 1e100]])
202
+ const results = new Set()
203
+ for (let i = 0; i < 100; i++) {
204
+ results.add(f())
205
+ }
206
+ expect(results.has('foo')).toBe(true)
207
+ expect(results.has('bar')).toBe(true)
208
+ })
209
+
210
+ test('very small frequencies work', () => {
211
+ const f = pickMe([['foo', 1e-100], ['bar', 1e-100]])
212
+ const results = new Set()
213
+ for (let i = 0; i < 100; i++) {
214
+ results.add(f())
215
+ }
216
+ expect(results.has('foo')).toBe(true)
217
+ expect(results.has('bar')).toBe(true)
218
+ })
219
+
220
+ test('duplicate items throw error', () => {
221
+ expect(() => pickMe([['foo', 1], ['foo', 2]])).toThrowError()
222
+ })
223
+
224
+ test('duplicate objects throw error', () => {
225
+ const obj = { id: 1 }
226
+ expect(() => pickMe([[obj, 1], [obj, 2]])).toThrowError()
227
+ })
228
+
229
+ test('single item always returns same item', () => {
230
+ const f = pickMe([['foo', 1]])
231
+ for (let i = 0; i < 100; i++) {
232
+ expect(f()).toBe('foo')
233
+ }
234
+ })
235
+
236
+ test('rng values < 0 treated as 0', () => {
237
+ const f = pickMe([['foo', 1], ['bar', 1]], () => -1)
238
+ // Should always return the first item
239
+ expect(f()).toBe('foo')
240
+ })
241
+
242
+ test('rng values > 1 treated as 1', () => {
243
+ const f = pickMe([['foo', 1], ['bar', 1]], () => 2)
244
+ // Should always return the last item (after sorting)
245
+ // Since we sort by frequency descending and both are 1, the order might vary
246
+ // Let's just check it returns one of them
247
+ const result = f()
248
+ expect(['foo', 'bar']).toContain(result)
249
+ })
250
+ })
package/src/index.ts CHANGED
@@ -15,10 +15,9 @@
15
15
  * pairs where all the frequencies are the same. (Note, this will not work if the items are all pairs whose
16
16
  * second value is a number. If you want to pick among such pairs you'll need to wrap them in new pairs.)
17
17
  *
18
- * An item with
19
- * a non-positive frequency will be culled from the list. `pickMe` will throw an error if the list is empty.
20
- * Likewise, it will throw an error if any item appears more than once in the list. `[]` is
21
- * not good. `[[1, 1], [1, 2]]` is no better.
18
+ * An item with a non-positive frequency will be culled from the list. `pickMe` will throw an error if the list is empty.
19
+ * Likewise, it will throw an error if any item appears more than once in the list. `[]` is not good.
20
+ * `[[1, 1], [1, 2]]` is no better.
22
21
  *
23
22
  * Note, the item returned by `pickMe` is the item you provided, not a copy. If you are picking from a list of
24
23
  * objects and you modify one of them, it will remain modified.
@@ -36,12 +35,12 @@
36
35
  * const fooBarBaz = pickMe([["foo", 1], ["bar", 2], ["baz", 3]], rng)
37
36
  * const ar: string[] = []
38
37
  * for (let i = 0; i < 10; i++) ar.push(fooBarBaz())
39
- * // => [ 'baz', 'foo', 'baz', 'baz', 'baz', 'bar', 'baz', 'baz', 'bar', 'baz' ]
38
+ * // => ['bar', 'baz', 'bar', 'foo', 'foo', 'baz', 'bar', 'bar', 'baz', 'foo']
40
39
  * ```
41
40
  *
42
- * @param {[T, number][] | T[]} frequencies - phrases to match
43
- * @param {Rng} [rando=() => Math.random()] - random number generator
44
- * @returns {() => T} a picker of T's
41
+ * @param frequencies - A list of pairs (item, frequency) or just items with equal frequency
42
+ * @param rando - Optional random number generator, defaults to Math.random
43
+ * @returns A function that returns items according to their specified frequencies
45
44
  */
46
45
  export function pickMe<T>(frequencies: [T, number][] | T[], rando?: Rng): () => T {
47
46
  const rng = rando ?? (() => Math.random())
@@ -58,113 +57,294 @@ export function pickMe<T>(frequencies: [T, number][] | T[], rando?: Rng): () =>
58
57
  *
59
58
  * const fooBarBaz = pickMeToo([["foo", 1], ["bar", 1000], ["baz", 1]])
60
59
  *
61
- * // minimum probablility generator just returns "foo"
60
+ * // minimum probability generator; this will always return the same thing, which happens to be "foo"
62
61
  * fooBarBaz(() => 0)()
63
62
  * // => "foo"
64
63
  *
65
- * // maximum probability generator just returns "baz"
64
+ * // maximum probability generator; this will always return the same thing, which happens to be "baz"
66
65
  * fooBarBaz(() => 1)()
67
66
  * // => "baz"
68
67
  * ```
69
68
  *
70
- * @template T - the sort of thing to pick
71
- * @param {[T, number][] | T[]} frequencies - things to pick according to their frequencies
72
- * @returns {(rng: Rng) => () => T} - a function from random number generators to pickers of things in the frequency list
69
+ * @param frequencies - things to pick according to their frequencies
70
+ * @returns a function from random number generators to pickers of things in the frequency list
73
71
  */
74
72
  export function pickMeToo<T>(frequencies: [T, number][] | T[]): (rng: Rng) => () => T {
75
- const simpleton = simplePicker(frequencies)
76
- if (simpleton) return simpleton
73
+ // Handle simple case: just items with no frequencies (all equal weight)
74
+ const simplePickerResult = trySimplePicker(frequencies)
75
+ if (simplePickerResult) {
76
+ return simplePickerResult
77
+ }
77
78
 
78
- frequencies = sanitize(frequencies as any as [T, number][])
79
+ // Convert to normalized [T, number][] format
80
+ const normalized = normalizeFrequencies(frequencies)
79
81
 
80
- // short-circuit edge cases
81
- if (frequencies.length === 0) throw new Error(`nothing to select in ${frequencies}`)
82
- if (frequencies.length === 1) {
83
- const [t] = frequencies[0]
84
- return (_rng: Rng) => () => t
85
- }
86
- dupChecker(frequencies, (item: [T, number] | T) => (item as [T, number])[0])
87
- let total = 0,
88
- acc = 0
89
- for (const [, n] of frequencies) total += n
90
- for (let i = 0; i < frequencies.length; i++) {
91
- const [, n] = frequencies[i]
92
- acc += n
93
- frequencies[i][1] = acc / total
82
+ // Validate and sanitize
83
+ if (normalized.length === 0) {
84
+ throw new Error('frequency list is empty or contains no valid items')
94
85
  }
95
- const items: T[] = [],
96
- thresholds: number[] = []
97
- for (const [t, n] of frequencies) {
98
- items.push(t)
99
- thresholds.push(n)
86
+
87
+ // Check for duplicates - O(n) using a Set for primitive types
88
+ checkForDuplicates(normalized)
89
+
90
+ // Handle edge cases
91
+ if (normalized.length === 1) {
92
+ const [t] = normalized[0]
93
+ return (_rng: Rng) => () => t
100
94
  }
101
- const f = thresholder(thresholds)
102
- return (rng: Rng) => () => items[f(rng())]!
95
+
96
+ // Build items array and sorted nodes with intervals
97
+ const [objects, nodes] = prepare(normalized)
98
+
99
+ // Build the search tree
100
+ const root = buildBranch(nodes)
101
+
102
+ // Add cumulative thresholds to the tree
103
+ addThresholds(root, 0)
104
+
105
+ // Generate the ternary expression
106
+ const exp = ternarize(root)
107
+
108
+ // Use Function constructor instead of eval for security
109
+ // This preserves the exact same computational properties as the original algorithm:
110
+ // - Wide intervals (high probability items) are checked first
111
+ // - Minimizes comparisons for common cases
112
+ // - Uses the tree structure for efficient searching
113
+ const picker = new Function('p', `return ${exp}`) as (p: number) => number
114
+
115
+ return (rng: Rng) => () => objects[picker(rng())]
103
116
  }
104
117
 
105
118
  /**
106
- * The type of random number generators. Ideally this will return number from a continuous distribution
107
- * in the interval [0, 1], but for `pickMe` and number less than 0 is the same as 0 and any number
108
- * greater than 1 is the same as 1.
119
+ * The type of random number generators. Ideally this will return numbers from a continuous distribution
120
+ * in the interval [0, 1], but for `pickMe` any number less than 0 is treated as 0 and any number
121
+ * greater than 1 is treated as 1.
109
122
  */
110
123
  export type Rng = () => number
111
124
 
112
- // throw an error in case of duplicates
113
- // it is difficult to do this efficiently because we allow T to be anything, so we can't for instance use a Set
114
- function dupChecker<T>(frequencies: [T, number][] | T[], toT: (item: [T, number] | T) => T) {
115
- for (let i = 0, lim = frequencies.length - 1; i < lim; i++) {
116
- const t = toT(frequencies[i])
117
- for (let j = i + 1; j < frequencies.length; j++) {
118
- if (t === toT(frequencies[j])) throw new Error(`${t} appears more than once among frequencies`)
119
- }
125
+ /**
126
+ * A node in the interval search tree.
127
+ */
128
+ type Node = {
129
+ index: number
130
+ interval: number
131
+ sum?: number
132
+ leftThreshold?: number
133
+ rightThreshold?: number
134
+ left?: Node
135
+ right?: Node
136
+ }
137
+
138
+ /**
139
+ * Converts a Node search tree into a nested ternary expression string.
140
+ * This preserves the original algorithm's optimization: wide intervals (high-probability items)
141
+ * are placed to minimize the number of comparisons needed for common cases.
142
+ */
143
+ function ternarize(n: Node): string {
144
+ if (n.left && n.right) {
145
+ return `(p < ${n.leftThreshold} ? ${ternarize(n.left)} : (p < ${n.rightThreshold} ? ${n.index} : ${ternarize(n.right)}))`
146
+ } else if (n.left) {
147
+ return `(p < ${n.leftThreshold} ? ${ternarize(n.left)} : ${n.index})`
148
+ } else if (n.right) {
149
+ return `(p < ${n.rightThreshold} ? ${n.index} : ${ternarize(n.right)})`
150
+ } else {
151
+ return n.index.toString()
120
152
  }
121
153
  }
122
154
 
123
- // if all frequencies are the same, we can make a simpler picker
124
- function simplePicker<T>(frequencies: [T, number][] | T[]): undefined | ((rng: Rng) => () => T) {
125
- if (frequencies.length === 0) return
126
- if ((frequencies as any).every((s: any) => Array.isArray(s) && s.length === 2 && typeof s[1] === 'number')) {
127
- const castFrequencies: [T, number][] = frequencies as any as [T, number][]
128
- const [, n1] = castFrequencies[0]
129
- if (n1 > 0 && castFrequencies.every(([, n2]) => n2 === n1)) {
130
- const items = castFrequencies.map(([t]) => t)
131
- dupChecker(items, (item: [T, number] | T) => item as T)
132
- return (rng: Rng) => () => items[Math.floor(items.length * rng())]
155
+ /**
156
+ * Checks if a value is a pair with a numeric second element.
157
+ */
158
+ function isFrequencyPair<T>(value: unknown): value is [T, number] {
159
+ return Array.isArray(value) && value.length === 2 && typeof value[1] === 'number'
160
+ }
161
+
162
+ /**
163
+ * Attempts to create a simple picker when all frequencies are equal.
164
+ * This is more efficient than the general case.
165
+ *
166
+ * @param frequencies - Input frequencies (pairs or simple items)
167
+ * @returns A picker function if all frequencies are equal, undefined otherwise
168
+ */
169
+ function trySimplePicker<T>(frequencies: [T, number][] | T[]): ((rng: Rng) => () => T) | undefined {
170
+ if (frequencies.length === 0) {
171
+ return undefined
172
+ }
173
+
174
+ // Check if first element looks like a pair with a number
175
+ const first = frequencies[0]
176
+ const isFirstPair = isFrequencyPair<T>(first)
177
+
178
+ if (isFirstPair) {
179
+ // Assume all are pairs
180
+ const typedFrequencies = frequencies as [T, number][]
181
+ const [, firstFreq] = typedFrequencies[0]
182
+
183
+ // Check if all have same positive frequency
184
+ if (firstFreq <= 0) {
185
+ return undefined
186
+ }
187
+
188
+ let allSame = true
189
+ for (let i = 1; i < typedFrequencies.length; i++) {
190
+ const [, n] = typedFrequencies[i]
191
+ if (n !== firstFreq) {
192
+ allSame = false
193
+ break
194
+ }
195
+ }
196
+
197
+ if (allSame) {
198
+ const items = typedFrequencies.map(([t]) => t)
199
+ checkForDuplicatesInItems(items)
200
+ return (rng: Rng) => () => {
201
+ const n = Math.max(0, Math.min(1, rng()))
202
+ return items[Math.min(items.length - 1, Math.floor(items.length * n))]
203
+ }
133
204
  }
134
205
  } else {
135
- const items: T[] = [...frequencies] as any as T[]
206
+ // Simple array of items (no frequencies specified)
207
+ const items: T[] = frequencies as T[]
136
208
  if (items.length === 1) {
137
209
  const t = items[0]
138
210
  return (_rng: Rng) => () => t
139
211
  }
140
- dupChecker(items, (item: [T, number] | T) => item as T)
141
- return (rng: Rng) => () => items[Math.floor(items.length * rng())]
212
+ checkForDuplicatesInItems(items)
213
+ return (rng: Rng) => () => {
214
+ const n = Math.max(0, Math.min(1, rng()))
215
+ return items[Math.min(items.length - 1, Math.floor(items.length * n))]
216
+ }
142
217
  }
218
+
219
+ return undefined
143
220
  }
144
221
 
145
- // remove impossible frequencies
146
- function sanitize<T>(frequencies: [T, number][]): [T, number][] {
147
- return frequencies.map(([t, n]) => [t, Number(n)]).filter(([, n]) => Number.isFinite(n) && n > 0) as any as [
148
- T,
149
- number,
150
- ][]
222
+ /**
223
+ * Normalizes input frequencies to [T, number][] format, filtering out invalid entries.
224
+ * Handles both [T, number][] and T[] inputs.
225
+ *
226
+ * @param frequencies - Input frequencies
227
+ * @returns Normalized frequencies as [T, number][]
228
+ */
229
+ function normalizeFrequencies<T>(frequencies: [T, number][] | T[]): [T, number][] {
230
+ if (frequencies.length === 0) {
231
+ return []
232
+ }
233
+
234
+ // Check if first element looks like a pair
235
+ const first = frequencies[0]
236
+ const isFirstPair = isFrequencyPair<T>(first)
237
+
238
+ if (isFirstPair) {
239
+ // Input is [T, number][] - sanitize each entry
240
+ const typedFrequencies = frequencies as [T, number][]
241
+ return typedFrequencies
242
+ .map(([t, n]) => [t, Number(n)] as [T, number])
243
+ .filter(([, n]) => Number.isFinite(n) && n > 0)
244
+ } else {
245
+ // Input is T[] - convert to [T, number][] with equal weights
246
+ const items = frequencies as T[]
247
+ return items.map(t => [t, 1] as [T, number])
248
+ }
151
249
  }
152
250
 
153
- // make a function that converts a random number into an index, preserving the frequencies
154
- // assuming the random number is from an even distribution in the interval [0, 1]
155
- function thresholder(thresholds: number[]): (n: number) => number {
156
- return eval(`(n) => ${portion(thresholds, 0, thresholds.length)}`)
251
+ /**
252
+ * Prepares nodes from frequencies. Sorts by frequency descending to prioritize
253
+ * high-probability items (they will be found with fewer comparisons).
254
+ *
255
+ * @param frequencies - Normalized frequencies
256
+ * @returns Tuple of items array and node array
257
+ */
258
+ function prepare<T>(frequencies: [T, number][]): [T[], Node[]] {
259
+ // Sort by frequency descending - this ensures wide intervals (high probability)
260
+ // are placed first in the tree, minimizing comparisons for common cases
261
+ frequencies.sort(([, na], [, nb]) => nb - na)
262
+ const sum = frequencies.reduce((acc, [, n]) => acc + n, 0)
263
+ const objects: T[] = []
264
+ const nodes: Node[] = []
265
+ frequencies.forEach(([t, n], i) => {
266
+ objects.push(t)
267
+ nodes.push({ interval: n / sum, index: i })
268
+ })
269
+ return [objects, nodes]
157
270
  }
158
271
 
159
- // recursively convert the threshold list into nested ternary operators
160
- // this allows us to find our index in O(log(n)) comparisons
161
- function portion(thresholds: number[], start: number, end: number): string {
162
- const n = end - start
163
- if (n === 1) return start.toString()
164
- const i = start + (n % 2 === 0 ? n / 2 - 1 : (n - 1) / 2)
165
- return `(n > ${thresholds[i]} ? ${portion(thresholds, i + 1, end)} : ${portion(thresholds, start, i + 1)})`
272
+ /**
273
+ * Builds a binary search tree from nodes, optimized for skewed distributions.
274
+ * Wide intervals (high probability) are placed to minimize comparisons.
275
+ *
276
+ * For distributions where one interval is >= 33% of the total (n.interval * 3 >= sum),
277
+ * a linear search is used for that subtree, which is more efficient than binary search.
278
+ *
279
+ * For balanced distributions, a heap-based split is used to maintain balance while
280
+ * prioritizing wide intervals.
281
+ *
282
+ * @param nodes - Array of nodes sorted by interval descending
283
+ * @returns The root node of the tree
284
+ */
285
+ function buildBranch(nodes: Node[]): Node {
286
+ const sum = nodes.reduce((acc, n) => acc + n.interval, 0)
287
+ const n = nodes.shift()!
288
+ n.sum = sum
289
+ if (nodes.length === 0) return n
290
+ if (n.interval * 3 >= sum) {
291
+ // The frequencies are so skewed that a linear search is more efficient
292
+ // than a binary search. Place all remaining nodes to the right.
293
+ n.right = buildBranch(nodes)
294
+ } else {
295
+ // Perform a binary search, considering wide intervals before narrow ones
296
+ // Use heap-based splitting to maintain balance while prioritizing wide intervals
297
+ const [left, right] = nodes.reduce(([left, right, idx], node) => {
298
+ if (belongsToLeftBranch(idx)) left.push(node)
299
+ else right.push(node)
300
+ return [left, right, idx + 1]
301
+ }, [[], [], 1] as [Node[], Node[], number])
302
+ n.left = buildBranch(left)
303
+ if (right.length > 0) n.right = buildBranch(right)
304
+ }
305
+ return n
166
306
  }
167
307
 
308
+ /**
309
+ * Determines if an index belongs to the left branch in a heap structure.
310
+ * This is used to split nodes into left and right subtrees while maintaining
311
+ * the heap property (wide intervals at the top).
312
+ *
313
+ * @param i - Index in the heap
314
+ * @returns true if the index belongs to the left branch
315
+ */
316
+ function belongsToLeftBranch(i: number): boolean {
317
+ if (i === 0) return true
318
+ if (i < 0) return false
319
+ return belongsToLeftBranch(Math.floor((i - 1) / 2))
320
+ }
321
+
322
+ /**
323
+ * Adds cumulative thresholds to nodes in the tree.
324
+ * This allows the ternary expression to efficiently search by comparing
325
+ * the random number against thresholds.
326
+ *
327
+ * @param n - Node to add thresholds to
328
+ * @param acc - Accumulated threshold value
329
+ */
330
+ function addThresholds(n: Node, acc: number) {
331
+ if (n.left) {
332
+ addThresholds(n.left, acc)
333
+ acc += n.left.sum!
334
+ n.leftThreshold = acc
335
+ }
336
+ if (n.right) {
337
+ acc += n.interval
338
+ n.rightThreshold = acc
339
+ addThresholds(n.right, acc)
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Constant for 2^32 used in random number generation.
345
+ */
346
+ const UINT32_MAX = 4294967296
347
+
168
348
  /**
169
349
  * Generates a random number sequence generator. This is a demonstration generator
170
350
  * useful for testing and documentation. Any zero-argument function that returns
@@ -172,7 +352,7 @@ function portion(thresholds: number[], start: number, end: number): string {
172
352
  *
173
353
  * The implementation of `rando` is taken from https://stackoverflow.com/a/47593316/15060051.
174
354
  * It is the Mulberry32 algorithm.
175
- *
355
+ *
176
356
  * Note, `rando` can be re-seeded by passing an optional number argument as the fresh seed (see
177
357
  * example below). This can be useful if you are sharing a random number generator among
178
358
  * several objects and you need to change random number sequences.
@@ -186,20 +366,20 @@ function portion(thresholds: number[], start: number, end: number): string {
186
366
  * const ar: number[] = []
187
367
  * for (let i = 0; i < 3; i++) ar.push(rng())
188
368
  * // => [ 0.6270739405881613, 0.002735721180215478, 0.5274470399599522 ]
189
- *
369
+ *
190
370
  * // the same seed will produce the same random number sequence
191
371
  * rando(1)()
192
372
  * // => 0.6270739405881613
193
373
  * rando(1)()
194
374
  * // => 0.6270739405881613
195
- *
375
+ *
196
376
  * // but you can provide a fresh seed to change the sequence
197
377
  * (rando(1) as any)(2)
198
378
  * // => 0.7342509443406016
199
379
  * ```
200
380
  *
201
- * @param {number} seed - the seed for a random number sequence
202
- * @returns {Rng} a generator of random numbers
381
+ * @param seed - the seed for a random number sequence
382
+ * @returns a generator of random numbers
203
383
  */
204
384
  export function rando(seed: number): Rng {
205
385
  return (reseed?: number) => {
@@ -207,6 +387,72 @@ export function rando(seed: number): Rng {
207
387
  let t = (seed += 0x6d2b79f5)
208
388
  t = Math.imul(t ^ (t >>> 15), t | 1)
209
389
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
210
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296
390
+ return ((t ^ (t >>> 14)) >>> 0) / UINT32_MAX
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Checks for duplicate items in a list of [T, number] pairs.
396
+ * Uses a Set for O(n) performance when T is a primitive type.
397
+ * Falls back to O(n²) for objects.
398
+ *
399
+ * @param frequencies - Array of [T, number] pairs
400
+ * @throws Error if duplicates are found
401
+ */
402
+ function checkForDuplicates<T>(frequencies: [T, number][]): void {
403
+ // Try to use Set for O(n) duplicate detection
404
+ const seen: any[] = []
405
+ let hasNonPrimitive = false
406
+
407
+ for (const [t] of frequencies) {
408
+ const type = typeof t
409
+ // Primitives that can be safely compared
410
+ if (type === 'string' || type === 'number' || type === 'boolean' || type === 'symbol' || type === 'bigint' || t === null) {
411
+ for (let i = 0; i < seen.length; i++) {
412
+ if (seen[i] === t) {
413
+ throw new Error(`${String(t)} appears more than once among frequencies`)
414
+ }
415
+ }
416
+ seen.push(t)
417
+ } else {
418
+ hasNonPrimitive = true
419
+ }
420
+ }
421
+
422
+ // For non-primitive types, we need O(n²) comparison
423
+ if (hasNonPrimitive) {
424
+ for (let i = 0; i < frequencies.length - 1; i++) {
425
+ const t1 = frequencies[i][0]
426
+ // Skip primitives we already checked
427
+ const type1 = typeof t1
428
+ if (type1 === 'string' || type1 === 'number' || type1 === 'boolean' || type1 === 'symbol' || type1 === 'bigint' || t1 === null) {
429
+ continue
430
+ }
431
+ for (let j = i + 1; j < frequencies.length; j++) {
432
+ const t2 = frequencies[j][0]
433
+ if (t1 === t2) {
434
+ throw new Error(`${String(t1)} appears more than once among frequencies`)
435
+ }
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Checks for duplicate items in a simple array of T.
443
+ *
444
+ * @param items - Array of items
445
+ * @throws Error if duplicates are found
446
+ */
447
+ function checkForDuplicatesInItems<T>(items: T[]): void {
448
+ // Try to use simple comparison
449
+ for (let i = 0; i < items.length - 1; i++) {
450
+ const t1 = items[i]
451
+ for (let j = i + 1; j < items.length; j++) {
452
+ const t2 = items[j]
453
+ if (t1 === t2) {
454
+ throw new Error(`${String(t1)} appears more than once among frequencies`)
455
+ }
456
+ }
211
457
  }
212
458
  }
package/tsconfig.json CHANGED
@@ -99,5 +99,5 @@
99
99
  "skipLibCheck": true /* Skip type checking all .d.ts files. */
100
100
  },
101
101
  "include": ["src"],
102
- "exclude": ["node_modules", "**/__tests__/*"]
102
+ "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"]
103
103
  }