@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
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
|
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useContext, createContext, ReactNode } from 'react';
|
|
2
|
+
import { motion, AnimatePresence, LayoutGroup } from 'motion/react';
|
|
3
|
+
import { BaseComponentProps } from '../mod';
|
|
4
|
+
import { registerSimulation } from '../utils/simulation';
|
|
5
|
+
import { registerFlattener } from '../utils/upload';
|
|
6
|
+
|
|
7
|
+
registerFlattener('Tutorial', 'session');
|
|
8
|
+
|
|
9
|
+
registerSimulation('Tutorial', (trialProps, _experimentState, simulators, participant) => {
|
|
10
|
+
const slideCount = trialProps.slides?.length ?? 0;
|
|
11
|
+
const slideData: Record<number, Record<string, unknown>> = {};
|
|
12
|
+
let totalDuration = 0;
|
|
13
|
+
for (let i = 0; i < slideCount; i++) {
|
|
14
|
+
const result = simulators.respondToSlide(i, trialProps, participant);
|
|
15
|
+
participant = result.participantState;
|
|
16
|
+
slideData[i] = result.value ?? {};
|
|
17
|
+
totalDuration += result.duration ?? 500;
|
|
18
|
+
}
|
|
19
|
+
return { responseData: { slides: slideData }, participantState: participant, duration: totalDuration };
|
|
20
|
+
}, {
|
|
21
|
+
respondToSlide: (_slideIndex: number, _trialProps: any, participant: any) => ({
|
|
22
|
+
value: {},
|
|
23
|
+
participantState: participant,
|
|
24
|
+
duration: 500,
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
interface TutorialSlideContextValue {
|
|
29
|
+
setCanProgress: (canProgress: boolean) => void;
|
|
30
|
+
autoAdvance: (delayMs?: number) => void;
|
|
31
|
+
setSlideData: (data: Record<string, unknown>) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TutorialSlideContext = createContext<TutorialSlideContextValue>({
|
|
35
|
+
setCanProgress: () => {},
|
|
36
|
+
autoAdvance: () => {},
|
|
37
|
+
setSlideData: () => {},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Hook for interactive slide components.
|
|
42
|
+
* Call with `{ locked: true }` to start with the next button disabled.
|
|
43
|
+
* Then call `unlock()` when the interaction is complete.
|
|
44
|
+
* Call `unlock({ autoAdvanceMs: 1000 })` to auto-advance after a delay.
|
|
45
|
+
*/
|
|
46
|
+
export const useTutorialSlide = ({ locked = false } = {}) => {
|
|
47
|
+
const ctx = useContext(TutorialSlideContext);
|
|
48
|
+
const initialized = useRef(false);
|
|
49
|
+
if (!initialized.current && locked) {
|
|
50
|
+
initialized.current = true;
|
|
51
|
+
ctx.setCanProgress(false);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
setData: ctx.setSlideData,
|
|
55
|
+
unlock: ({ autoAdvanceMs }: { autoAdvanceMs?: number } = {}) => {
|
|
56
|
+
ctx.setCanProgress(true);
|
|
57
|
+
if (autoAdvanceMs !== undefined) ctx.autoAdvance(autoAdvanceMs);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface TutorialProps extends BaseComponentProps {
|
|
63
|
+
slides: ReactNode[];
|
|
64
|
+
/** Fade duration in seconds (default: 0.3) */
|
|
65
|
+
fadeDuration?: number;
|
|
66
|
+
finishText?: string;
|
|
67
|
+
containerClass?: string;
|
|
68
|
+
/** Key to go forward (default: 'ArrowRight', set to false to disable) */
|
|
69
|
+
nextKey?: string | false;
|
|
70
|
+
/** Key to go back (default: 'ArrowLeft', set to false to disable) */
|
|
71
|
+
backKey?: string | false;
|
|
72
|
+
/** Color mode for dot indicators (default: 'light') */
|
|
73
|
+
theme?: 'light' | 'dark';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const NAV_BTN =
|
|
77
|
+
'px-4 py-3 border-2 border-black font-bold text-lg rounded-xl shadow-[2px_2px_0px_rgba(0,0,0,1)] transition-all duration-100 outline-none focus:outline-none focus:ring-0';
|
|
78
|
+
|
|
79
|
+
const NAV_BTN_ACTIVE = NAV_BTN + ' bg-white text-black cursor-pointer hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none';
|
|
80
|
+
|
|
81
|
+
const NAV_BTN_DISABLED = NAV_BTN + ' bg-gray-200 text-gray-400 border-gray-400 shadow-[2px_2px_0px_rgba(156,163,175,1)] cursor-default';
|
|
82
|
+
|
|
83
|
+
export const Tutorial = ({
|
|
84
|
+
next,
|
|
85
|
+
slides,
|
|
86
|
+
fadeDuration = 0.3,
|
|
87
|
+
finishText = 'Start',
|
|
88
|
+
containerClass,
|
|
89
|
+
nextKey = 'ArrowRight',
|
|
90
|
+
backKey = 'ArrowLeft',
|
|
91
|
+
theme = 'light',
|
|
92
|
+
}: TutorialProps) => {
|
|
93
|
+
const [page, setPage] = useState(0);
|
|
94
|
+
const [progressMap, setProgressMap] = useState<Record<number, boolean>>({});
|
|
95
|
+
const autoAdvanceTimer = useRef<ReturnType<typeof setTimeout>>();
|
|
96
|
+
const dataMap = useRef<Record<number, Record<string, unknown>>>({});
|
|
97
|
+
|
|
98
|
+
const canProgress = progressMap[page] !== false;
|
|
99
|
+
|
|
100
|
+
const handleSetCanProgress = useCallback(
|
|
101
|
+
(value: boolean) => {
|
|
102
|
+
setProgressMap((prev) => ({ ...prev, [page]: value }));
|
|
103
|
+
},
|
|
104
|
+
[page],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const handleSetSlideData = useCallback(
|
|
108
|
+
(data: Record<string, unknown>) => {
|
|
109
|
+
dataMap.current[page] = { ...dataMap.current[page], ...data };
|
|
110
|
+
},
|
|
111
|
+
[page],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const goNext = useCallback(() => {
|
|
115
|
+
if (page === slides.length - 1) next({ slides: dataMap.current });
|
|
116
|
+
else setPage((p) => p + 1);
|
|
117
|
+
}, [page, slides.length, next]);
|
|
118
|
+
|
|
119
|
+
const goNextIfCan = useCallback(() => {
|
|
120
|
+
if (!canProgress) return;
|
|
121
|
+
goNext();
|
|
122
|
+
}, [canProgress, goNext]);
|
|
123
|
+
|
|
124
|
+
const handleAutoAdvance = useCallback(
|
|
125
|
+
(delayMs = 0) => {
|
|
126
|
+
if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current);
|
|
127
|
+
autoAdvanceTimer.current = setTimeout(() => goNext(), delayMs);
|
|
128
|
+
},
|
|
129
|
+
[goNext],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
useEffect(() => () => { if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current); }, []);
|
|
133
|
+
|
|
134
|
+
const goBack = useCallback(() => {
|
|
135
|
+
if (page > 0) setPage((p) => p - 1);
|
|
136
|
+
}, [page]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (nextKey === false && backKey === false) return;
|
|
140
|
+
const onKey = (e: KeyboardEvent) => {
|
|
141
|
+
if (nextKey && e.key === nextKey) goNextIfCan();
|
|
142
|
+
if (backKey && e.key === backKey) goBack();
|
|
143
|
+
};
|
|
144
|
+
window.addEventListener('keydown', onKey);
|
|
145
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
146
|
+
}, [nextKey, backKey, goNextIfCan, goBack]);
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<LayoutGroup>
|
|
150
|
+
<div
|
|
151
|
+
className={containerClass}
|
|
152
|
+
style={{
|
|
153
|
+
width: '100vw',
|
|
154
|
+
height: '100vh',
|
|
155
|
+
display: 'flex',
|
|
156
|
+
flexDirection: 'column',
|
|
157
|
+
overflow: 'hidden',
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
<div style={{ flex: 1, position: 'relative' }}>
|
|
161
|
+
<AnimatePresence mode='sync'>
|
|
162
|
+
<motion.div
|
|
163
|
+
key={page}
|
|
164
|
+
initial={{ opacity: 0 }}
|
|
165
|
+
animate={{ opacity: 1 }}
|
|
166
|
+
exit={{ opacity: 0 }}
|
|
167
|
+
transition={{ duration: fadeDuration }}
|
|
168
|
+
style={{
|
|
169
|
+
position: 'absolute',
|
|
170
|
+
inset: 0,
|
|
171
|
+
display: 'flex',
|
|
172
|
+
alignItems: 'center',
|
|
173
|
+
justifyContent: 'center',
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
<TutorialSlideContext.Provider value={{ setCanProgress: handleSetCanProgress, autoAdvance: handleAutoAdvance, setSlideData: handleSetSlideData }}>
|
|
177
|
+
{slides[page]}
|
|
178
|
+
</TutorialSlideContext.Provider>
|
|
179
|
+
</motion.div>
|
|
180
|
+
</AnimatePresence>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div
|
|
184
|
+
style={{
|
|
185
|
+
padding: '1.5rem',
|
|
186
|
+
display: 'grid',
|
|
187
|
+
gridTemplateColumns: '1fr auto 1fr',
|
|
188
|
+
alignItems: 'center',
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
<div style={{ justifySelf: 'end', marginRight: '1rem' }}>
|
|
192
|
+
<button
|
|
193
|
+
onClick={goBack}
|
|
194
|
+
className={NAV_BTN_ACTIVE}
|
|
195
|
+
tabIndex={-1}
|
|
196
|
+
style={{ visibility: page > 0 ? 'visible' : 'hidden' }}
|
|
197
|
+
>
|
|
198
|
+
←
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
203
|
+
{slides.map((_, i) => (
|
|
204
|
+
<div
|
|
205
|
+
key={i}
|
|
206
|
+
style={{
|
|
207
|
+
width: 8,
|
|
208
|
+
height: 8,
|
|
209
|
+
borderRadius: '50%',
|
|
210
|
+
backgroundColor: i === page
|
|
211
|
+
? (theme === 'dark' ? '#fff' : '#000')
|
|
212
|
+
: (theme === 'dark' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)'),
|
|
213
|
+
transition: 'background-color 0.2s',
|
|
214
|
+
}}
|
|
215
|
+
/>
|
|
216
|
+
))}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div style={{ justifySelf: 'start', marginLeft: '1rem' }}>
|
|
220
|
+
<button
|
|
221
|
+
onClick={goNextIfCan}
|
|
222
|
+
tabIndex={-1}
|
|
223
|
+
className={(canProgress ? NAV_BTN_ACTIVE : NAV_BTN_DISABLED) + (page === slides.length - 1 ? ' px-8' : '')}
|
|
224
|
+
>
|
|
225
|
+
{page === slides.length - 1 ? finishText : '→'}
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</LayoutGroup>
|
|
231
|
+
);
|
|
232
|
+
};
|
|
@@ -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
|
+
});
|