@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.
@@ -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 };
@@ -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 = Array.isArray(config.participants)
166
- ? config.participants.length
167
- : config.participants.count;
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
  });
@@ -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 participants = simulationConfig.participants;
10
- const participant = Array.isArray(participants) ? participants[index] : participants.generator(index);
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
- // Each participant is an object whose properties are available in simulator decision functions.
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
- participants: {
171
- generator: (i: number) => ({
172
- id: i,
173
- nickname: `participant_${i}`,
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
  };