@adriansteffan/reactive 0.1.1 → 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-Beb0Bz3s.js → mod-DRCLdWzq.js} +54565 -32232
- package/dist/mod.d.ts +170 -1
- package/dist/reactive.es.js +52 -41
- package/dist/reactive.umd.js +10207 -52
- package/dist/style.css +1 -1
- package/dist/{web-DOXm98lr.js → web-CWqttxrD.js} +1 -1
- package/dist/{web-aMUVS_EB.js → web-N9H86cSP.js} +1 -1
- package/package.json +9 -1
- package/rdk_doc.md +341 -0
- package/src/components/canvasblock.tsx +4 -3
- package/src/components/experimentrunner.tsx +5 -1
- package/src/components/index.ts +4 -0
- package/src/components/mobilefilepermission.tsx +2 -0
- package/src/components/plaininput.tsx +2 -1
- package/src/components/quest.tsx +8 -7
- package/src/components/randomdotkinetogram.tsx +1014 -0
- package/src/components/text.tsx +2 -1
- package/src/components/tutorial.tsx +232 -0
- 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
|
@@ -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
|
});
|
package/template/simulate.ts
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
|
-
import { orchestrateSimulation, simulateParticipant, setBackendUrl } from '@adriansteffan/reactive';
|
|
2
|
-
import { experiment, simulationConfig } from './src/Experiment';
|
|
1
|
+
import { orchestrateSimulation, simulateParticipant, setBackendUrl, seedDistributions } from '@adriansteffan/reactive';
|
|
3
2
|
|
|
4
3
|
// Each simulated participant runs as a separate subprocess to get fresh module-level
|
|
5
4
|
// randomization (e.g., group assignment). The orchestrator spawns workers that re-run
|
|
6
5
|
// this script with _REACTIVE_WORKER_INDEX set.
|
|
6
|
+
//
|
|
7
|
+
// Experiment.tsx is imported dynamically so that seeding happens before module-level
|
|
8
|
+
// code runs. If participants is a factory function, it is called with the base seed
|
|
9
|
+
// for deterministic generation, then re-seeded per-worker for the simulation.
|
|
7
10
|
if (process.env._REACTIVE_WORKER_INDEX) {
|
|
8
11
|
const index = parseInt(process.env._REACTIVE_WORKER_INDEX);
|
|
9
|
-
const
|
|
10
|
-
|
|
12
|
+
const seed = process.env._REACTIVE_SIMULATION_SEED;
|
|
13
|
+
if (seed) seedDistributions(parseInt(seed) + index);
|
|
11
14
|
setBackendUrl(process.env._REACTIVE_BACKEND_URL!);
|
|
15
|
+
|
|
16
|
+
const { experiment, simulationConfig } = await import('./src/Experiment');
|
|
17
|
+
const participants = simulationConfig.participants;
|
|
18
|
+
|
|
19
|
+
let participant;
|
|
20
|
+
if (typeof participants === 'function') {
|
|
21
|
+
if (seed) seedDistributions(parseInt(seed));
|
|
22
|
+
const allParticipants = participants();
|
|
23
|
+
if (seed) seedDistributions(parseInt(seed) + index);
|
|
24
|
+
participant = allParticipants[index];
|
|
25
|
+
} else if (Array.isArray(participants)) {
|
|
26
|
+
participant = participants[index];
|
|
27
|
+
} else {
|
|
28
|
+
participant = participants.generator(index);
|
|
29
|
+
}
|
|
30
|
+
|
|
12
31
|
await simulateParticipant(experiment, participant);
|
|
13
32
|
} else {
|
|
33
|
+
const { simulationConfig } = await import('./src/Experiment');
|
|
14
34
|
await orchestrateSimulation(simulationConfig, import.meta.filename);
|
|
15
35
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { useState, useRef } from 'react';
|
|
3
|
-
import { ExperimentRunner, BaseComponentProps, ExperimentConfig, registerSimulation, registerFlattener } from '@adriansteffan/reactive';
|
|
3
|
+
import { ExperimentRunner, BaseComponentProps, ExperimentConfig, registerSimulation, registerFlattener, sampleParticipants } from '@adriansteffan/reactive';
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
const config: ExperimentConfig = { showProgressBar: true };
|
|
@@ -165,13 +165,11 @@ export default function Experiment() {
|
|
|
165
165
|
|
|
166
166
|
// --- Simulation config ---
|
|
167
167
|
// Define how simulated participants are generated.
|
|
168
|
-
//
|
|
168
|
+
// Use a factory function for participants: the framework seeds it with the base seed,
|
|
169
|
+
// so every worker generates the same participant list regardless of its per-worker seed.
|
|
169
170
|
export const simulationConfig = {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}),
|
|
175
|
-
count: 10,
|
|
176
|
-
},
|
|
171
|
+
seed: 42,
|
|
172
|
+
participants: () => sampleParticipants('sobol', 10, {
|
|
173
|
+
needForCognition: { distribution: 'normal', mean: 3.5, sd: 0.8 },
|
|
174
|
+
}).map((p, i) => ({ ...p, id: i, nickname: `participant_${i}` })),
|
|
177
175
|
};
|