@adriansteffan/reactive 0.1.2 → 0.1.3
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/.claude/settings.local.json +13 -1
- package/README.md +184 -5
- package/dist/{mod-Bb_FAy0j.js → mod-DRCLdWzq.js} +31177 -14827
- package/dist/mod.d.ts +40 -1
- package/dist/reactive.es.js +36 -29
- package/dist/reactive.umd.js +10202 -55
- package/dist/style.css +1 -1
- package/dist/{web-DmsP-pmx.js → web-CWqttxrD.js} +1 -1
- package/dist/{web-BfWhpr9p.js → web-N9H86cSP.js} +1 -1
- package/package.json +8 -1
- package/src/components/canvasblock.tsx +4 -3
- package/src/components/plaininput.tsx +2 -1
- package/src/components/quest.tsx +8 -7
- package/src/components/randomdotkinetogram.tsx +3 -2
- package/src/components/text.tsx +2 -1
- package/src/components/upload.tsx +2 -1
- package/src/mod.tsx +2 -0
- package/src/utils/array.ts +4 -2
- package/src/utils/distributions.test.ts +209 -0
- package/src/utils/distributions.ts +191 -0
- package/src/utils/simulation.ts +9 -4
- package/template/simulate.ts +24 -4
- package/template/src/Experiment.tsx +7 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adriansteffan/reactive",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "vite",
|
|
@@ -40,9 +40,16 @@
|
|
|
40
40
|
"@capacitor/filesystem": "^7.0.0",
|
|
41
41
|
"@capacitor/share": "^7.0.0",
|
|
42
42
|
"@capacitor/status-bar": "^7.0.0",
|
|
43
|
+
"@stdlib/random-base-mt19937": "^0.2.3",
|
|
44
|
+
"@stdlib/random-base-normal": "^0.2.2",
|
|
45
|
+
"@stdlib/random-base-poisson": "^0.2.2",
|
|
46
|
+
"@stdlib/random-base-uniform": "^0.2.3",
|
|
47
|
+
"@stdlib/stats-base-dists-normal-quantile": "^0.3.1",
|
|
48
|
+
"@stdlib/stats-base-dists-poisson-quantile": "^0.2.3",
|
|
43
49
|
"@tanstack/react-query": "^5.61.3",
|
|
44
50
|
"@tanstack/react-query-devtools": "^5.61.3",
|
|
45
51
|
"@zip.js/zip.js": "^2.7.53",
|
|
52
|
+
"halton": "^1.0.0",
|
|
46
53
|
"motion": "^12.38.0",
|
|
47
54
|
"react": "^18.2.0",
|
|
48
55
|
"react-dom": "^18.2.0",
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { BaseComponentProps, isFullscreen, now } from '../utils/common';
|
|
17
17
|
import { registerSimulation, ParticipantState } from '../utils/simulation';
|
|
18
18
|
import { registerFlattener, arrayFlattener } from '../utils/upload';
|
|
19
|
+
import { uniform } from '../utils/distributions';
|
|
19
20
|
|
|
20
21
|
registerFlattener('CanvasBlock', 'canvas', arrayFlattener);
|
|
21
22
|
|
|
@@ -557,12 +558,12 @@ registerSimulation('CanvasBlock', (trialProps, experimentState, simulators, part
|
|
|
557
558
|
respondToSlide: (slide: any, participant: any) => {
|
|
558
559
|
const keys = slide.allowedKeys;
|
|
559
560
|
if (keys === true) {
|
|
560
|
-
return { key: ' ', reactionTime: 200
|
|
561
|
+
return { key: ' ', reactionTime: uniform(200, 800), participantState: participant };
|
|
561
562
|
}
|
|
562
563
|
if (Array.isArray(keys) && keys.length > 0) {
|
|
563
564
|
return {
|
|
564
|
-
key: keys[Math.floor(
|
|
565
|
-
reactionTime: 200
|
|
565
|
+
key: keys[Math.floor(uniform(0, keys.length))],
|
|
566
|
+
reactionTime: uniform(200, 800),
|
|
566
567
|
participantState: participant,
|
|
567
568
|
};
|
|
568
569
|
}
|
|
@@ -2,12 +2,13 @@ import { BaseComponentProps } from '../mod';
|
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { registerSimulation } from '../utils/simulation';
|
|
4
4
|
import { registerFlattener } from '../utils/upload';
|
|
5
|
+
import { uniform } from '../utils/distributions';
|
|
5
6
|
|
|
6
7
|
registerFlattener('PlainInput', 'session');
|
|
7
8
|
|
|
8
9
|
registerSimulation('PlainInput', (trialProps, _experimentState, simulators, participant) => {
|
|
9
10
|
const result = simulators.respond(trialProps, participant);
|
|
10
|
-
const typingDuration = String(result.value).length * (50
|
|
11
|
+
const typingDuration = String(result.value).length * uniform(50, 150);
|
|
11
12
|
return {
|
|
12
13
|
responseData: { value: result.value },
|
|
13
14
|
participantState: result.participantState,
|
package/src/components/quest.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { ContrastLight } from 'survey-core/themes';
|
|
|
5
5
|
import 'survey-core/survey-core.min.css';
|
|
6
6
|
import { registerSimulation } from '../utils/simulation';
|
|
7
7
|
import { registerFlattener } from '../utils/upload';
|
|
8
|
+
import { uniform } from '../utils/distributions';
|
|
8
9
|
|
|
9
10
|
registerFlattener('Quest', 'session');
|
|
10
11
|
|
|
@@ -28,21 +29,21 @@ registerSimulation('Quest', (trialProps, _experimentState, simulators, participa
|
|
|
28
29
|
switch (question.type) {
|
|
29
30
|
case 'rating': {
|
|
30
31
|
const min = question.rateMin ?? 1, max = question.rateMax ?? 5;
|
|
31
|
-
value = min + Math.floor(
|
|
32
|
+
value = min + Math.floor(uniform(0, max - min + 1));
|
|
32
33
|
break;
|
|
33
34
|
}
|
|
34
|
-
case 'boolean': value =
|
|
35
|
+
case 'boolean': value = uniform(0, 1) > 0.5; break;
|
|
35
36
|
case 'text': case 'comment': value = 'simulated_response'; break;
|
|
36
37
|
case 'radiogroup': case 'dropdown': {
|
|
37
|
-
const c = question.choices?.[Math.floor(
|
|
38
|
+
const c = question.choices?.[Math.floor(uniform(0, question.choices?.length || 0))];
|
|
38
39
|
value = c !== undefined ? (typeof c === 'object' ? c.value : c) : null;
|
|
39
40
|
break;
|
|
40
41
|
}
|
|
41
42
|
case 'checkbox': {
|
|
42
43
|
if (question.choices?.length) {
|
|
43
|
-
const n = 1 + Math.floor(
|
|
44
|
+
const n = 1 + Math.floor(uniform(0, question.choices.length));
|
|
44
45
|
value = [...question.choices]
|
|
45
|
-
.sort(() =>
|
|
46
|
+
.sort(() => uniform(0, 1) - 0.5).slice(0, n)
|
|
46
47
|
.map((c: any) => typeof c === 'object' ? c.value : c);
|
|
47
48
|
}
|
|
48
49
|
break;
|
|
@@ -51,7 +52,7 @@ registerSimulation('Quest', (trialProps, _experimentState, simulators, participa
|
|
|
51
52
|
if (question.rows?.length && question.columns?.length) {
|
|
52
53
|
value = Object.fromEntries(
|
|
53
54
|
question.rows.map((r: any) => {
|
|
54
|
-
const col = question.columns[Math.floor(
|
|
55
|
+
const col = question.columns[Math.floor(uniform(0, question.columns.length))];
|
|
55
56
|
return [typeof r === 'object' ? r.value : r, typeof col === 'object' ? col.value : col];
|
|
56
57
|
}),
|
|
57
58
|
);
|
|
@@ -60,7 +61,7 @@ registerSimulation('Quest', (trialProps, _experimentState, simulators, participa
|
|
|
60
61
|
}
|
|
61
62
|
default: value = null;
|
|
62
63
|
}
|
|
63
|
-
return { value, participantState: participant, duration: 1000
|
|
64
|
+
return { value, participantState: participant, duration: uniform(1000, 5000) };
|
|
64
65
|
},
|
|
65
66
|
});
|
|
66
67
|
|
|
@@ -11,6 +11,7 @@ import type { CSSProperties } from 'react';
|
|
|
11
11
|
import { BaseComponentProps, shuffle } from '../mod';
|
|
12
12
|
import { registerSimulation } from '../utils/simulation';
|
|
13
13
|
import { registerFlattener } from '../utils/upload';
|
|
14
|
+
import { uniform } from '../utils/distributions';
|
|
14
15
|
|
|
15
16
|
registerFlattener('RandomDotKinematogram', 'rdk');
|
|
16
17
|
|
|
@@ -63,10 +64,10 @@ registerSimulation('RandomDotKinematogram', (trialProps, _experimentState, simul
|
|
|
63
64
|
}, {
|
|
64
65
|
respond: (trialProps: any, participant: any) => {
|
|
65
66
|
const merged = { ...RDK_DEFAULTS, ...trialProps };
|
|
66
|
-
const rawRt = 200
|
|
67
|
+
const rawRt = uniform(200, 800);
|
|
67
68
|
const maxRt = merged.duration ?? RDK_DEFAULTS.duration;
|
|
68
69
|
const responded = merged.validKeys.length > 0 && rawRt <= maxRt;
|
|
69
|
-
const key = responded ? merged.validKeys[Math.floor(
|
|
70
|
+
const key = responded ? merged.validKeys[Math.floor(uniform(0, merged.validKeys.length))] : null;
|
|
70
71
|
const rt = responded ? rawRt : null;
|
|
71
72
|
const correctKeys = Array.isArray(merged.correctResponse)
|
|
72
73
|
? merged.correctResponse.map((c: string) => c.toLowerCase())
|
package/src/components/text.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
|
|
2
2
|
import { BaseComponentProps, now } from '../mod';
|
|
3
3
|
import { registerSimulation } from '../utils/simulation';
|
|
4
4
|
import { registerFlattener } from '../utils/upload';
|
|
5
|
+
import { uniform } from '../utils/distributions';
|
|
5
6
|
|
|
6
7
|
registerFlattener('Text', 'text');
|
|
7
8
|
|
|
@@ -10,7 +11,7 @@ registerSimulation('Text', (trialProps, _experimentState, simulators, participan
|
|
|
10
11
|
return { responseData: result.value, participantState: result.participantState, duration: result.value.reactionTime };
|
|
11
12
|
}, {
|
|
12
13
|
respond: (_input: any, participant: any) => ({
|
|
13
|
-
value: { key: 'button', reactionTime: 500
|
|
14
|
+
value: { key: 'button', reactionTime: uniform(500, 2000) },
|
|
14
15
|
participantState: participant,
|
|
15
16
|
}),
|
|
16
17
|
});
|
|
@@ -16,9 +16,10 @@ import { BlobWriter, TextReader, ZipWriter } from '@zip.js/zip.js';
|
|
|
16
16
|
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
|
17
17
|
import { buildUploadFiles, convertArrayOfObjectsToCSV } from '../utils/upload';
|
|
18
18
|
import { registerSimulation, getBackendUrl, getInitialParticipant } from '../utils/simulation';
|
|
19
|
+
import { uniform } from '../utils/distributions';
|
|
19
20
|
|
|
20
21
|
registerSimulation('Upload', async (trialProps, experimentState, _simulators, participant) => {
|
|
21
|
-
const sessionID = trialProps.sessionID || `sim_${Date.now()}_${Math.
|
|
22
|
+
const sessionID = trialProps.sessionID || `sim_${Date.now()}_${Math.floor(uniform(0, 36 ** 6)).toString(36)}`;
|
|
22
23
|
const files = buildUploadFiles({
|
|
23
24
|
sessionID,
|
|
24
25
|
data: experimentState.data || [],
|
package/src/mod.tsx
CHANGED
|
@@ -6,6 +6,8 @@ export * from './utils/array';
|
|
|
6
6
|
export * from './utils/common';
|
|
7
7
|
export * from './utils/simulation';
|
|
8
8
|
export { registerFlattener, arrayFlattener } from './utils/upload';
|
|
9
|
+
export { normal, poisson, uniform, seedDistributions, sobol, halton, sampleParticipants } from './utils/distributions';
|
|
10
|
+
export type { DimensionSpec, UniformSpec, NormalSpec, PoissonSpec, SamplingMethod } from './utils/distributions';
|
|
9
11
|
export * from './components';
|
|
10
12
|
|
|
11
13
|
export * from 'react-toastify';
|
package/src/utils/array.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { uniform } from './distributions';
|
|
2
|
+
|
|
1
3
|
declare global {
|
|
2
4
|
interface Array<T> {
|
|
3
5
|
/**
|
|
@@ -36,7 +38,7 @@ export function sample<T>(array: T[], n: number = 1): T[] {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
for (let i = 0; i < n; i++) {
|
|
39
|
-
const randomIndex = Math.floor(
|
|
41
|
+
const randomIndex = Math.floor(uniform(0, 1) * array.length);
|
|
40
42
|
result.push(array[randomIndex]);
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -50,7 +52,7 @@ export function shuffle<T>(array: T[]): T[] {
|
|
|
50
52
|
const result = [...array];
|
|
51
53
|
|
|
52
54
|
for (let i = result.length - 1; i >= 0; i--) {
|
|
53
|
-
const j = Math.floor(
|
|
55
|
+
const j = Math.floor(uniform(0, 1) * (i + 1));
|
|
54
56
|
[result[i], result[j]] = [result[j], result[i]];
|
|
55
57
|
}
|
|
56
58
|
return result;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sobol, halton, seedDistributions, normal, uniform, poisson, sampleParticipants } from './distributions';
|
|
3
|
+
|
|
4
|
+
// Known Sobol sequence values (1D, Van der Corput base 2, skipping the zero point)
|
|
5
|
+
// Reference: https://en.wikipedia.org/wiki/Van_der_Corput_sequence
|
|
6
|
+
const SOBOL_1D_EXPECTED = [0.5, 0.75, 0.25, 0.375, 0.875, 0.625, 0.125, 0.1875];
|
|
7
|
+
|
|
8
|
+
// Known Halton sequence values (base 2 and 3, skipping the zero point)
|
|
9
|
+
// Reference: https://en.wikipedia.org/wiki/Halton_sequence
|
|
10
|
+
const HALTON_2D_EXPECTED = [
|
|
11
|
+
[0.5, 1 / 3],
|
|
12
|
+
[0.25, 2 / 3],
|
|
13
|
+
[0.75, 1 / 9],
|
|
14
|
+
[0.125, 4 / 9],
|
|
15
|
+
[0.625, 7 / 9],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
describe('sobol', () => {
|
|
19
|
+
it('produces known 1D Van der Corput values', () => {
|
|
20
|
+
const result = sobol(8, [{ distribution: 'uniform', min: 0, max: 1 }]) as number[];
|
|
21
|
+
expect(result).toHaveLength(8);
|
|
22
|
+
for (let i = 0; i < SOBOL_1D_EXPECTED.length; i++) {
|
|
23
|
+
expect(result[i]).toBeCloseTo(SOBOL_1D_EXPECTED[i], 10);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('produces known 2D values', () => {
|
|
28
|
+
const result = sobol(4, [
|
|
29
|
+
{ distribution: 'uniform', min: 0, max: 1 },
|
|
30
|
+
{ distribution: 'uniform', min: 0, max: 1 },
|
|
31
|
+
]) as number[][];
|
|
32
|
+
// First 2D Sobol points (after skipping zero): [0.5, 0.5], [0.75, 0.25], [0.25, 0.75]
|
|
33
|
+
expect(result[0]).toEqual([expect.closeTo(0.5, 10), expect.closeTo(0.5, 10)]);
|
|
34
|
+
expect(result[1]).toEqual([expect.closeTo(0.75, 10), expect.closeTo(0.25, 10)]);
|
|
35
|
+
expect(result[2]).toEqual([expect.closeTo(0.25, 10), expect.closeTo(0.75, 10)]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('scales uniform dimensions correctly', () => {
|
|
39
|
+
const result = sobol(3, [{ distribution: 'uniform', min: 200, max: 800 }]) as number[];
|
|
40
|
+
// 0.5 * 600 + 200 = 500, 0.75 * 600 + 200 = 650, 0.25 * 600 + 200 = 350
|
|
41
|
+
expect(result[0]).toBeCloseTo(500, 10);
|
|
42
|
+
expect(result[1]).toBeCloseTo(650, 10);
|
|
43
|
+
expect(result[2]).toBeCloseTo(350, 10);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('produces correct count of points', () => {
|
|
47
|
+
const result = sobol(100, [{ distribution: 'uniform', min: 0, max: 1 }]) as number[];
|
|
48
|
+
expect(result).toHaveLength(100);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns flat array for 1D, nested for multi-D', () => {
|
|
52
|
+
const r1 = sobol(3, [{ distribution: 'uniform', min: 0, max: 1 }]);
|
|
53
|
+
expect(typeof r1[0]).toBe('number');
|
|
54
|
+
|
|
55
|
+
const r2 = sobol(3, [
|
|
56
|
+
{ distribution: 'uniform', min: 0, max: 1 },
|
|
57
|
+
{ distribution: 'uniform', min: 0, max: 1 },
|
|
58
|
+
]);
|
|
59
|
+
expect(Array.isArray(r2[0])).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('all 1D values are in [0, 1] for unit range', () => {
|
|
63
|
+
const result = sobol(1000, [{ distribution: 'uniform', min: 0, max: 1 }]) as number[];
|
|
64
|
+
for (const v of result) {
|
|
65
|
+
expect(v).toBeGreaterThanOrEqual(0);
|
|
66
|
+
expect(v).toBeLessThan(1);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('produces no duplicate points in 2D', () => {
|
|
71
|
+
const result = sobol(100, [
|
|
72
|
+
{ distribution: 'uniform', min: 0, max: 1 },
|
|
73
|
+
{ distribution: 'uniform', min: 0, max: 1 },
|
|
74
|
+
]) as number[][];
|
|
75
|
+
const keys = new Set(result.map(([a, b]) => `${a},${b}`));
|
|
76
|
+
expect(keys.size).toBe(100);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('is deterministic (same call twice gives same result)', () => {
|
|
80
|
+
const a = sobol(50, [{ distribution: 'uniform', min: 0, max: 1 }]);
|
|
81
|
+
const b = sobol(50, [{ distribution: 'uniform', min: 0, max: 1 }]);
|
|
82
|
+
expect(a).toEqual(b);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('throws for dimensions > 21', () => {
|
|
86
|
+
const specs = Array.from({ length: 22 }, () => ({ distribution: 'uniform' as const, min: 0, max: 1 }));
|
|
87
|
+
expect(() => sobol(10, specs)).toThrow('1-21 dimensions');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('halton', () => {
|
|
92
|
+
it('produces known 2D values (bases 2, 3)', () => {
|
|
93
|
+
const result = halton(5, [
|
|
94
|
+
{ distribution: 'uniform', min: 0, max: 1 },
|
|
95
|
+
{ distribution: 'uniform', min: 0, max: 1 },
|
|
96
|
+
]) as number[][];
|
|
97
|
+
for (let i = 0; i < HALTON_2D_EXPECTED.length; i++) {
|
|
98
|
+
expect(result[i][0]).toBeCloseTo(HALTON_2D_EXPECTED[i][0], 10);
|
|
99
|
+
expect(result[i][1]).toBeCloseTo(HALTON_2D_EXPECTED[i][1], 10);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('is deterministic', () => {
|
|
104
|
+
const a = halton(50, [{ distribution: 'uniform', min: 0, max: 1 }]);
|
|
105
|
+
const b = halton(50, [{ distribution: 'uniform', min: 0, max: 1 }]);
|
|
106
|
+
expect(a).toEqual(b);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('normal distribution spec', () => {
|
|
111
|
+
it('produces values centered around the mean', () => {
|
|
112
|
+
const result = sobol(1000, [{ distribution: 'normal', mean: 500, sd: 100 }]) as number[];
|
|
113
|
+
const avg = result.reduce((a, b) => a + b, 0) / result.length;
|
|
114
|
+
expect(avg).toBeCloseTo(500, 0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('median is close to mean (symmetric distribution)', () => {
|
|
118
|
+
const result = sobol(1000, [{ distribution: 'normal', mean: 500, sd: 100 }]) as number[];
|
|
119
|
+
const sorted = [...result].sort((a, b) => a - b);
|
|
120
|
+
const median = sorted[500];
|
|
121
|
+
expect(median).toBeCloseTo(500, 0);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('poisson distribution spec', () => {
|
|
126
|
+
it('produces non-negative integers', () => {
|
|
127
|
+
const result = sobol(100, [{ distribution: 'poisson', mean: 5 }]) as number[];
|
|
128
|
+
for (const v of result) {
|
|
129
|
+
expect(v).toBeGreaterThanOrEqual(0);
|
|
130
|
+
expect(Number.isInteger(v)).toBe(true);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('mean is close to lambda', () => {
|
|
135
|
+
const result = sobol(1000, [{ distribution: 'poisson', mean: 5 }]) as number[];
|
|
136
|
+
const avg = result.reduce((a, b) => a + b, 0) / result.length;
|
|
137
|
+
expect(avg).toBeCloseTo(5, 0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('sampleParticipants', () => {
|
|
142
|
+
it('returns objects with the correct keys', () => {
|
|
143
|
+
const result = sampleParticipants('sobol', 5, {
|
|
144
|
+
rtMean: { distribution: 'normal', mean: 500, sd: 100 },
|
|
145
|
+
threshold: { distribution: 'uniform', min: 0.3, max: 0.7 },
|
|
146
|
+
});
|
|
147
|
+
expect(result).toHaveLength(5);
|
|
148
|
+
for (const p of result) {
|
|
149
|
+
expect(p).toHaveProperty('rtMean');
|
|
150
|
+
expect(p).toHaveProperty('threshold');
|
|
151
|
+
expect(typeof p.rtMean).toBe('number');
|
|
152
|
+
expect(typeof p.threshold).toBe('number');
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('sobol produces deterministic results', () => {
|
|
157
|
+
const a = sampleParticipants('sobol', 10, { x: { distribution: 'uniform', min: 0, max: 1 } });
|
|
158
|
+
const b = sampleParticipants('sobol', 10, { x: { distribution: 'uniform', min: 0, max: 1 } });
|
|
159
|
+
expect(a).toEqual(b);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('halton produces deterministic results', () => {
|
|
163
|
+
const a = sampleParticipants('halton', 10, { x: { distribution: 'uniform', min: 0, max: 1 } });
|
|
164
|
+
const b = sampleParticipants('halton', 10, { x: { distribution: 'uniform', min: 0, max: 1 } });
|
|
165
|
+
expect(a).toEqual(b);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('random method produces valid values', () => {
|
|
169
|
+
const result = sampleParticipants('random', 50, {
|
|
170
|
+
score: { distribution: 'poisson', mean: 5 },
|
|
171
|
+
});
|
|
172
|
+
expect(result).toHaveLength(50);
|
|
173
|
+
for (const p of result) {
|
|
174
|
+
expect(Number.isInteger(p.score)).toBe(true);
|
|
175
|
+
expect(p.score).toBeGreaterThanOrEqual(0);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('works with a single dimension', () => {
|
|
180
|
+
const result = sampleParticipants('sobol', 3, {
|
|
181
|
+
rt: { distribution: 'uniform', min: 200, max: 800 },
|
|
182
|
+
});
|
|
183
|
+
expect(result[0].rt).toBeCloseTo(500, 10);
|
|
184
|
+
expect(result[1].rt).toBeCloseTo(650, 10);
|
|
185
|
+
expect(result[2].rt).toBeCloseTo(350, 10);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('seedDistributions', () => {
|
|
190
|
+
it('makes pseudorandom samplers reproducible', () => {
|
|
191
|
+
seedDistributions(42);
|
|
192
|
+
const a = [normal(0, 1), uniform(0, 10), poisson(5)];
|
|
193
|
+
|
|
194
|
+
seedDistributions(42);
|
|
195
|
+
const b = [normal(0, 1), uniform(0, 10), poisson(5)];
|
|
196
|
+
|
|
197
|
+
expect(a).toEqual(b);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('different seeds produce different sequences', () => {
|
|
201
|
+
seedDistributions(1);
|
|
202
|
+
const a = normal(0, 1);
|
|
203
|
+
|
|
204
|
+
seedDistributions(2);
|
|
205
|
+
const b = normal(0, 1);
|
|
206
|
+
|
|
207
|
+
expect(a).not.toBe(b);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import mt19937 from '@stdlib/random-base-mt19937';
|
|
2
|
+
import normalFactory from '@stdlib/random-base-normal';
|
|
3
|
+
import poissonFactory from '@stdlib/random-base-poisson';
|
|
4
|
+
import uniformFactory from '@stdlib/random-base-uniform';
|
|
5
|
+
import normalQuantile from '@stdlib/stats-base-dists-normal-quantile';
|
|
6
|
+
import poissonQuantile from '@stdlib/stats-base-dists-poisson-quantile';
|
|
7
|
+
import generateHalton from 'halton';
|
|
8
|
+
|
|
9
|
+
// --- Pseudorandom samplers (seedable) ---
|
|
10
|
+
|
|
11
|
+
let normal: (mu: number, sigma: number) => number = normalFactory;
|
|
12
|
+
let poisson: (lambda: number) => number = poissonFactory;
|
|
13
|
+
let uniform: (min: number, max: number) => number = uniformFactory;
|
|
14
|
+
|
|
15
|
+
interface StdlibPRNG {
|
|
16
|
+
normalized: () => number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function seedDistributions(seed: number): void {
|
|
20
|
+
const prng = mt19937.factory({ seed }) as unknown as StdlibPRNG;
|
|
21
|
+
normal = normalFactory.factory({ prng: prng.normalized });
|
|
22
|
+
poisson = poissonFactory.factory({ prng: prng.normalized });
|
|
23
|
+
uniform = uniformFactory.factory({ prng: prng.normalized });
|
|
24
|
+
Math.random = () => uniform(0, 1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- QMC dimension specs ---
|
|
28
|
+
// Inverse CDF transform: https://en.wikipedia.org/wiki/Inverse_transform_sampling
|
|
29
|
+
|
|
30
|
+
type UniformSpec = { distribution: 'uniform'; min: number; max: number };
|
|
31
|
+
type NormalSpec = { distribution: 'normal'; mean: number; sd: number };
|
|
32
|
+
type PoissonSpec = { distribution: 'poisson'; mean: number };
|
|
33
|
+
type DimensionSpec = UniformSpec | NormalSpec | PoissonSpec;
|
|
34
|
+
|
|
35
|
+
// Inverse CDFs return +-Infinity at exactly 0 or 1, so clamp to open interval (0, 1).
|
|
36
|
+
function clampUnit(value: number): number {
|
|
37
|
+
return Math.max(1e-10, Math.min(1 - 1e-10, value));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveTransform(spec: DimensionSpec): (unitValue: number) => number {
|
|
41
|
+
switch (spec.distribution) {
|
|
42
|
+
case 'uniform':
|
|
43
|
+
return (u) => spec.min + u * (spec.max - spec.min);
|
|
44
|
+
case 'normal':
|
|
45
|
+
return (u) => normalQuantile(clampUnit(u), spec.mean, spec.sd);
|
|
46
|
+
case 'poisson':
|
|
47
|
+
// QMC variance reduction is less effective on discrete distributions
|
|
48
|
+
// due to integer clumping at quantile boundaries.
|
|
49
|
+
return (u) => poissonQuantile(clampUnit(u), spec.mean);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function transformPoints(unitPoints: number[][], specs: DimensionSpec[]): number[] | number[][] {
|
|
54
|
+
const transforms = specs.map(resolveTransform);
|
|
55
|
+
if (specs.length === 1) {
|
|
56
|
+
return unitPoints.map((point) => transforms[0](point[0]));
|
|
57
|
+
}
|
|
58
|
+
return unitPoints.map((point) =>
|
|
59
|
+
point.map((unitValue, dim) => transforms[dim](unitValue)),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Sobol sequence (browser-compatible implementation as existing packages want to use fs) ---
|
|
64
|
+
// Algorithm: Bratley & Fox (1988), https://doi.org/10.1145/42288.214372
|
|
65
|
+
// Joe-Kuo direction numbers for dimensions 2-21 (dimension 1 uses Van der Corput base 2).
|
|
66
|
+
// Each entry is [degree, polynomialCoefficients, initialDirectionNumbers].
|
|
67
|
+
// Direction numbers: Joe & Kuo (2008), https://doi.org/10.1137/070709359
|
|
68
|
+
// Data: https://web.maths.unsw.edu.au/~fkuo/sobol/joe-kuo-old.1111
|
|
69
|
+
const JOE_KUO: [number, number, number[]][] = [
|
|
70
|
+
[1, 0, [1]], // dim 2
|
|
71
|
+
[2, 1, [1, 1]], // dim 3
|
|
72
|
+
[3, 1, [1, 3, 7]], // dim 4
|
|
73
|
+
[3, 2, [1, 1, 5]], // dim 5
|
|
74
|
+
[4, 1, [1, 3, 1, 1]], // dim 6
|
|
75
|
+
[4, 4, [1, 1, 3, 7]], // dim 7
|
|
76
|
+
[5, 2, [1, 3, 3, 9, 9]], // dim 8
|
|
77
|
+
[5, 13, [1, 3, 7, 13, 3]], // dim 9
|
|
78
|
+
[5, 7, [1, 1, 5, 11, 27]], // dim 10
|
|
79
|
+
[5, 14, [1, 3, 5, 1, 15]], // dim 11
|
|
80
|
+
[5, 11, [1, 1, 7, 3, 29]], // dim 12
|
|
81
|
+
[5, 4, [1, 3, 7, 7, 21]], // dim 13
|
|
82
|
+
[6, 1, [1, 1, 1, 9, 23, 37]], // dim 14
|
|
83
|
+
[6, 16, [1, 3, 3, 5, 19, 33]], // dim 15
|
|
84
|
+
[6, 13, [1, 1, 3, 13, 11, 7]], // dim 16
|
|
85
|
+
[6, 22, [1, 1, 7, 13, 25, 5]], // dim 17
|
|
86
|
+
[6, 19, [1, 3, 5, 11, 7, 11]], // dim 18
|
|
87
|
+
[6, 25, [1, 1, 1, 3, 13, 39]], // dim 19
|
|
88
|
+
[7, 1, [1, 3, 1, 15, 17, 63, 13]], // dim 20
|
|
89
|
+
[7, 32, [1, 1, 5, 5, 1, 59, 33]], // dim 21
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const BITS = 32;
|
|
93
|
+
const SCALE = 2 ** BITS;
|
|
94
|
+
|
|
95
|
+
function buildDirectionNumbers(dimension: number): Uint32Array {
|
|
96
|
+
const directions = new Uint32Array(BITS);
|
|
97
|
+
if (dimension === 0) {
|
|
98
|
+
for (let i = 0; i < BITS; i++) directions[i] = 1 << (BITS - 1 - i);
|
|
99
|
+
return directions;
|
|
100
|
+
}
|
|
101
|
+
const [degree, coefficients, initial] = JOE_KUO[dimension - 1];
|
|
102
|
+
for (let i = 0; i < degree; i++) directions[i] = initial[i] << (BITS - 1 - i);
|
|
103
|
+
for (let i = degree; i < BITS; i++) {
|
|
104
|
+
directions[i] = directions[i - degree] ^ (directions[i - degree] >>> degree);
|
|
105
|
+
for (let j = 1; j < degree; j++) {
|
|
106
|
+
if ((coefficients >>> (degree - 1 - j)) & 1) directions[i] ^= directions[i - j];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return directions;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function generateSobol(count: number, dimensions: number): number[][] {
|
|
113
|
+
if (dimensions < 1 || dimensions > 21) {
|
|
114
|
+
throw new RangeError('sobol() supports 1-21 dimensions');
|
|
115
|
+
}
|
|
116
|
+
const allDirections = Array.from({ length: dimensions }, (_, dim) => buildDirectionNumbers(dim));
|
|
117
|
+
const state = new Uint32Array(dimensions);
|
|
118
|
+
const points: number[][] = [new Array<number>(dimensions).fill(0)];
|
|
119
|
+
for (let index = 1; index < count; index++) {
|
|
120
|
+
// Find the position of the rightmost zero bit
|
|
121
|
+
let rightmostZero = 0;
|
|
122
|
+
let bits = index - 1;
|
|
123
|
+
while ((bits & 1) === 1) { bits >>>= 1; rightmostZero++; }
|
|
124
|
+
for (let dim = 0; dim < dimensions; dim++) state[dim] ^= allDirections[dim][rightmostZero];
|
|
125
|
+
points.push(Array.from(state, (value) => value / SCALE));
|
|
126
|
+
}
|
|
127
|
+
return points;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Halton sequence ---
|
|
131
|
+
// Halton (1960), https://doi.org/10.1007/BF01386213
|
|
132
|
+
const PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73];
|
|
133
|
+
const HALTON_MAX_EFFECTIVE_DIMS = 6;
|
|
134
|
+
|
|
135
|
+
function sobol(count: number, specs: DimensionSpec[]): number[] | number[][] {
|
|
136
|
+
// Generate count+1 and skip the first point (always zero in every dimension)
|
|
137
|
+
const unitPoints = generateSobol(count + 1, specs.length).slice(1);
|
|
138
|
+
return transformPoints(unitPoints, specs);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function halton(count: number, specs: DimensionSpec[]): number[] | number[][] {
|
|
142
|
+
if (specs.length > HALTON_MAX_EFFECTIVE_DIMS) {
|
|
143
|
+
console.warn(`Halton sequence quality degrades above ${HALTON_MAX_EFFECTIVE_DIMS} dimensions. Consider using sobol() instead.`);
|
|
144
|
+
}
|
|
145
|
+
const bases = PRIMES.slice(0, specs.length);
|
|
146
|
+
// Generate count+1 and skip the first point (always zero in every dimension)
|
|
147
|
+
const unitPoints = (generateHalton(count + 1, bases) as number[][]).slice(1);
|
|
148
|
+
return transformPoints(unitPoints, specs);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type SamplingMethod = 'sobol' | 'halton' | 'random';
|
|
152
|
+
|
|
153
|
+
function sampleParticipants<T extends Record<string, DimensionSpec>>(
|
|
154
|
+
method: SamplingMethod,
|
|
155
|
+
count: number,
|
|
156
|
+
specs: T,
|
|
157
|
+
): Array<{ [K in keyof T]: number }> {
|
|
158
|
+
const keys = Object.keys(specs) as (keyof T & string)[];
|
|
159
|
+
const dimSpecs = keys.map((k) => specs[k]);
|
|
160
|
+
|
|
161
|
+
let points: number[] | number[][];
|
|
162
|
+
switch (method) {
|
|
163
|
+
case 'sobol':
|
|
164
|
+
points = sobol(count, dimSpecs);
|
|
165
|
+
break;
|
|
166
|
+
case 'halton':
|
|
167
|
+
points = halton(count, dimSpecs);
|
|
168
|
+
break;
|
|
169
|
+
case 'random':
|
|
170
|
+
points = randomPoints(count, dimSpecs);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return Array.from({ length: count }, (_, i) => {
|
|
175
|
+
const row = keys.length === 1 ? [points[i] as number] : (points[i] as number[]);
|
|
176
|
+
return Object.fromEntries(keys.map((k, d) => [k, row[d]])) as { [K in keyof T]: number };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function randomPoints(count: number, specs: DimensionSpec[]): number[] | number[][] {
|
|
181
|
+
const transforms = specs.map(resolveTransform);
|
|
182
|
+
if (specs.length === 1) {
|
|
183
|
+
return Array.from({ length: count }, () => transforms[0](uniform(0, 1)));
|
|
184
|
+
}
|
|
185
|
+
return Array.from({ length: count }, () =>
|
|
186
|
+
transforms.map((t) => t(uniform(0, 1))),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export { normal, poisson, uniform, seedDistributions, sobol, halton, sampleParticipants };
|
|
191
|
+
export type { DimensionSpec, UniformSpec, NormalSpec, PoissonSpec, SamplingMethod };
|
package/src/utils/simulation.ts
CHANGED
|
@@ -151,7 +151,9 @@ export async function simulateParticipant(
|
|
|
151
151
|
|
|
152
152
|
|
|
153
153
|
export interface RunSimulationConfig {
|
|
154
|
-
participants: ParticipantState[] | { generator: (index: number) => ParticipantState; count: number };
|
|
154
|
+
participants: ParticipantState[] | { generator: (index: number) => ParticipantState; count: number } | (() => ParticipantState[]);
|
|
155
|
+
/** Seed for reproducible simulations. Each participant gets seed + workerIndex. */
|
|
156
|
+
seed?: number;
|
|
155
157
|
backendPort?: number;
|
|
156
158
|
concurrency?: number;
|
|
157
159
|
}
|
|
@@ -162,9 +164,11 @@ export async function orchestrateSimulation(config: RunSimulationConfig, scriptP
|
|
|
162
164
|
const port = config.backendPort ?? 8001;
|
|
163
165
|
const backendUrl = `http://localhost:${port}/backend`;
|
|
164
166
|
|
|
165
|
-
const participantCount =
|
|
166
|
-
? config.participants.length
|
|
167
|
-
: config.participants
|
|
167
|
+
const participantCount = typeof config.participants === 'function'
|
|
168
|
+
? config.participants().length
|
|
169
|
+
: Array.isArray(config.participants)
|
|
170
|
+
? config.participants.length
|
|
171
|
+
: config.participants.count;
|
|
168
172
|
|
|
169
173
|
const backend = spawn('npx', ['tsx', 'src/backend.ts'], {
|
|
170
174
|
cwd: './backend',
|
|
@@ -212,6 +216,7 @@ export async function orchestrateSimulation(config: RunSimulationConfig, scriptP
|
|
|
212
216
|
...process.env,
|
|
213
217
|
_REACTIVE_WORKER_INDEX: String(i),
|
|
214
218
|
_REACTIVE_BACKEND_URL: backendUrl,
|
|
219
|
+
...(config.seed !== undefined ? { _REACTIVE_SIMULATION_SEED: String(config.seed) } : {}),
|
|
215
220
|
},
|
|
216
221
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
217
222
|
});
|