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 +20 -0
- package/CHANGELOG.md +14 -0
- package/README.md +2 -2
- package/lib/index.d.ts +16 -18
- package/lib/index.js +299 -79
- package/package.json +7 -2
- package/src/__tests__/documentation.test.ts +3 -3
- package/src/__tests__/functionality.test.ts +132 -1
- package/src/index.ts +330 -84
- package/tsconfig.json +1 -1
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
|
-
// => "
|
|
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', '
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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', '
|
|
38
|
+
* // => ['bar', 'baz', 'bar', 'foo', 'foo', 'baz', 'bar', 'bar', 'baz', 'foo']
|
|
40
39
|
* ```
|
|
41
40
|
*
|
|
42
|
-
* @param
|
|
43
|
-
* @param
|
|
44
|
-
* @returns
|
|
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
|
|
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
|
|
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
|
-
* @
|
|
67
|
-
* @
|
|
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
|
|
73
|
-
* in the interval [0, 1], but for `pickMe`
|
|
74
|
-
* greater than 1 is
|
|
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
|
|
111
|
-
* @returns
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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', '
|
|
41
|
+
* // => ['bar', 'baz', 'bar', 'foo', 'foo', 'baz', 'bar', 'bar', 'baz', 'foo']
|
|
43
42
|
* ```
|
|
44
43
|
*
|
|
45
|
-
* @param
|
|
46
|
-
* @param
|
|
47
|
-
* @returns
|
|
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
|
|
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
|
|
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
|
-
* @
|
|
74
|
-
* @
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
return (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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
193
|
-
* @returns
|
|
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) /
|
|
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": "
|
|
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("
|
|
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', '
|
|
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('
|
|
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(
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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', '
|
|
38
|
+
* // => ['bar', 'baz', 'bar', 'foo', 'foo', 'baz', 'bar', 'bar', 'baz', 'foo']
|
|
40
39
|
* ```
|
|
41
40
|
*
|
|
42
|
-
* @param
|
|
43
|
-
* @param
|
|
44
|
-
* @returns
|
|
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
|
|
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
|
|
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
|
-
* @
|
|
71
|
-
* @
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
+
// Convert to normalized [T, number][] format
|
|
80
|
+
const normalized = normalizeFrequencies(frequencies)
|
|
79
81
|
|
|
80
|
-
//
|
|
81
|
-
if (
|
|
82
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
107
|
-
* in the interval [0, 1], but for `pickMe`
|
|
108
|
-
* greater than 1 is
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
return (rng: 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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
202
|
-
* @returns
|
|
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) /
|
|
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