@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.
@@ -1,4 +1,4 @@
1
- import { W as e } from "./mod-Bb_FAy0j.js";
1
+ import { W as e } from "./mod-DRCLdWzq.js";
2
2
  class s extends e {
3
3
  async enable() {
4
4
  console.log("Immersive mode is only available on Android");
@@ -1,4 +1,4 @@
1
- import { W as P, b as x, E } from "./mod-Bb_FAy0j.js";
1
+ import { W as P, b as x, E } from "./mod-DRCLdWzq.js";
2
2
  function m(w) {
3
3
  const e = w.split("/").filter((t) => t !== "."), r = [];
4
4
  return e.forEach((t) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adriansteffan/reactive",
3
- "version": "0.1.2",
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 + Math.random() * 600, participantState: participant };
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(Math.random() * keys.length)],
565
- reactionTime: 200 + Math.random() * 600,
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 + Math.random() * 100);
11
+ const typingDuration = String(result.value).length * uniform(50, 150);
11
12
  return {
12
13
  responseData: { value: result.value },
13
14
  participantState: result.participantState,
@@ -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(Math.random() * (max - min + 1));
32
+ value = min + Math.floor(uniform(0, max - min + 1));
32
33
  break;
33
34
  }
34
- case 'boolean': value = Math.random() > 0.5; break;
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(Math.random() * (question.choices?.length || 0))];
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(Math.random() * question.choices.length);
44
+ const n = 1 + Math.floor(uniform(0, question.choices.length));
44
45
  value = [...question.choices]
45
- .sort(() => Math.random() - 0.5).slice(0, n)
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(Math.random() * question.columns.length)];
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 + Math.random() * 4000 };
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 + Math.random() * 600;
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(Math.random() * merged.validKeys.length)] : null;
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())
@@ -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 + Math.random() * 1500 },
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.random().toString(36).slice(2, 8)}`;
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';
@@ -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(Math.random() * array.length);
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(Math.random() * (i + 1));
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 };
@@ -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
  });