bead 0.1.0__py3-none-any.whl
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.
- bead/__init__.py +11 -0
- bead/__main__.py +11 -0
- bead/active_learning/__init__.py +15 -0
- bead/active_learning/config.py +231 -0
- bead/active_learning/loop.py +566 -0
- bead/active_learning/models/__init__.py +24 -0
- bead/active_learning/models/base.py +852 -0
- bead/active_learning/models/binary.py +910 -0
- bead/active_learning/models/categorical.py +943 -0
- bead/active_learning/models/cloze.py +862 -0
- bead/active_learning/models/forced_choice.py +956 -0
- bead/active_learning/models/free_text.py +773 -0
- bead/active_learning/models/lora.py +365 -0
- bead/active_learning/models/magnitude.py +835 -0
- bead/active_learning/models/multi_select.py +795 -0
- bead/active_learning/models/ordinal_scale.py +811 -0
- bead/active_learning/models/peft_adapter.py +155 -0
- bead/active_learning/models/random_effects.py +639 -0
- bead/active_learning/selection.py +354 -0
- bead/active_learning/strategies.py +391 -0
- bead/active_learning/trainers/__init__.py +26 -0
- bead/active_learning/trainers/base.py +210 -0
- bead/active_learning/trainers/data_collator.py +172 -0
- bead/active_learning/trainers/dataset_utils.py +261 -0
- bead/active_learning/trainers/huggingface.py +304 -0
- bead/active_learning/trainers/lightning.py +324 -0
- bead/active_learning/trainers/metrics.py +424 -0
- bead/active_learning/trainers/mixed_effects.py +551 -0
- bead/active_learning/trainers/model_wrapper.py +509 -0
- bead/active_learning/trainers/registry.py +104 -0
- bead/adapters/__init__.py +11 -0
- bead/adapters/huggingface.py +61 -0
- bead/behavioral/__init__.py +116 -0
- bead/behavioral/analytics.py +646 -0
- bead/behavioral/extraction.py +343 -0
- bead/behavioral/merging.py +343 -0
- bead/cli/__init__.py +11 -0
- bead/cli/active_learning.py +513 -0
- bead/cli/active_learning_commands.py +779 -0
- bead/cli/completion.py +359 -0
- bead/cli/config.py +624 -0
- bead/cli/constraint_builders.py +286 -0
- bead/cli/deployment.py +859 -0
- bead/cli/deployment_trials.py +493 -0
- bead/cli/deployment_ui.py +332 -0
- bead/cli/display.py +378 -0
- bead/cli/items.py +960 -0
- bead/cli/items_factories.py +776 -0
- bead/cli/list_constraints.py +714 -0
- bead/cli/lists.py +490 -0
- bead/cli/main.py +430 -0
- bead/cli/models.py +877 -0
- bead/cli/resource_loaders.py +621 -0
- bead/cli/resources.py +1036 -0
- bead/cli/shell.py +356 -0
- bead/cli/simulate.py +840 -0
- bead/cli/templates.py +1158 -0
- bead/cli/training.py +1080 -0
- bead/cli/utils.py +614 -0
- bead/cli/workflow.py +1273 -0
- bead/config/__init__.py +68 -0
- bead/config/active_learning.py +1009 -0
- bead/config/config.py +192 -0
- bead/config/defaults.py +118 -0
- bead/config/deployment.py +217 -0
- bead/config/env.py +147 -0
- bead/config/item.py +45 -0
- bead/config/list.py +193 -0
- bead/config/loader.py +149 -0
- bead/config/logging.py +42 -0
- bead/config/model.py +49 -0
- bead/config/paths.py +46 -0
- bead/config/profiles.py +320 -0
- bead/config/resources.py +47 -0
- bead/config/serialization.py +210 -0
- bead/config/simulation.py +206 -0
- bead/config/template.py +238 -0
- bead/config/validation.py +267 -0
- bead/data/__init__.py +65 -0
- bead/data/base.py +87 -0
- bead/data/identifiers.py +97 -0
- bead/data/language_codes.py +61 -0
- bead/data/metadata.py +270 -0
- bead/data/range.py +123 -0
- bead/data/repository.py +358 -0
- bead/data/serialization.py +249 -0
- bead/data/timestamps.py +89 -0
- bead/data/validation.py +349 -0
- bead/data_collection/__init__.py +11 -0
- bead/data_collection/jatos.py +223 -0
- bead/data_collection/merger.py +154 -0
- bead/data_collection/prolific.py +198 -0
- bead/deployment/__init__.py +5 -0
- bead/deployment/distribution.py +402 -0
- bead/deployment/jatos/__init__.py +1 -0
- bead/deployment/jatos/api.py +200 -0
- bead/deployment/jatos/exporter.py +210 -0
- bead/deployment/jspsych/__init__.py +9 -0
- bead/deployment/jspsych/biome.json +44 -0
- bead/deployment/jspsych/config.py +411 -0
- bead/deployment/jspsych/generator.py +598 -0
- bead/deployment/jspsych/package.json +51 -0
- bead/deployment/jspsych/pnpm-lock.yaml +2141 -0
- bead/deployment/jspsych/randomizer.py +299 -0
- bead/deployment/jspsych/src/lib/list-distributor.test.ts +327 -0
- bead/deployment/jspsych/src/lib/list-distributor.ts +1282 -0
- bead/deployment/jspsych/src/lib/randomizer.test.ts +232 -0
- bead/deployment/jspsych/src/lib/randomizer.ts +367 -0
- bead/deployment/jspsych/src/plugins/cloze-dropdown.ts +252 -0
- bead/deployment/jspsych/src/plugins/forced-choice.ts +265 -0
- bead/deployment/jspsych/src/plugins/plugins.test.ts +141 -0
- bead/deployment/jspsych/src/plugins/rating.ts +248 -0
- bead/deployment/jspsych/src/slopit/index.ts +9 -0
- bead/deployment/jspsych/src/types/jatos.d.ts +256 -0
- bead/deployment/jspsych/src/types/jspsych.d.ts +228 -0
- bead/deployment/jspsych/templates/experiment.css +1 -0
- bead/deployment/jspsych/templates/experiment.js.template +289 -0
- bead/deployment/jspsych/templates/index.html +51 -0
- bead/deployment/jspsych/templates/randomizer.js +241 -0
- bead/deployment/jspsych/templates/randomizer.js.template +313 -0
- bead/deployment/jspsych/trials.py +723 -0
- bead/deployment/jspsych/tsconfig.json +23 -0
- bead/deployment/jspsych/tsup.config.ts +30 -0
- bead/deployment/jspsych/ui/__init__.py +1 -0
- bead/deployment/jspsych/ui/components.py +383 -0
- bead/deployment/jspsych/ui/styles.py +411 -0
- bead/dsl/__init__.py +80 -0
- bead/dsl/ast.py +168 -0
- bead/dsl/context.py +178 -0
- bead/dsl/errors.py +71 -0
- bead/dsl/evaluator.py +570 -0
- bead/dsl/grammar.lark +81 -0
- bead/dsl/parser.py +231 -0
- bead/dsl/stdlib.py +929 -0
- bead/evaluation/__init__.py +13 -0
- bead/evaluation/convergence.py +485 -0
- bead/evaluation/interannotator.py +398 -0
- bead/items/__init__.py +40 -0
- bead/items/adapters/__init__.py +70 -0
- bead/items/adapters/anthropic.py +224 -0
- bead/items/adapters/api_utils.py +167 -0
- bead/items/adapters/base.py +216 -0
- bead/items/adapters/google.py +259 -0
- bead/items/adapters/huggingface.py +1074 -0
- bead/items/adapters/openai.py +323 -0
- bead/items/adapters/registry.py +202 -0
- bead/items/adapters/sentence_transformers.py +224 -0
- bead/items/adapters/togetherai.py +309 -0
- bead/items/binary.py +515 -0
- bead/items/cache.py +558 -0
- bead/items/categorical.py +593 -0
- bead/items/cloze.py +757 -0
- bead/items/constructor.py +784 -0
- bead/items/forced_choice.py +413 -0
- bead/items/free_text.py +681 -0
- bead/items/generation.py +432 -0
- bead/items/item.py +396 -0
- bead/items/item_template.py +787 -0
- bead/items/magnitude.py +573 -0
- bead/items/multi_select.py +621 -0
- bead/items/ordinal_scale.py +569 -0
- bead/items/scoring.py +448 -0
- bead/items/validation.py +723 -0
- bead/lists/__init__.py +30 -0
- bead/lists/balancer.py +263 -0
- bead/lists/constraints.py +1067 -0
- bead/lists/experiment_list.py +286 -0
- bead/lists/list_collection.py +378 -0
- bead/lists/partitioner.py +1141 -0
- bead/lists/stratification.py +254 -0
- bead/participants/__init__.py +73 -0
- bead/participants/collection.py +699 -0
- bead/participants/merging.py +312 -0
- bead/participants/metadata_spec.py +491 -0
- bead/participants/models.py +276 -0
- bead/resources/__init__.py +29 -0
- bead/resources/adapters/__init__.py +19 -0
- bead/resources/adapters/base.py +104 -0
- bead/resources/adapters/cache.py +128 -0
- bead/resources/adapters/glazing.py +508 -0
- bead/resources/adapters/registry.py +117 -0
- bead/resources/adapters/unimorph.py +796 -0
- bead/resources/classification.py +856 -0
- bead/resources/constraint_builders.py +329 -0
- bead/resources/constraints.py +165 -0
- bead/resources/lexical_item.py +223 -0
- bead/resources/lexicon.py +744 -0
- bead/resources/loaders.py +209 -0
- bead/resources/template.py +441 -0
- bead/resources/template_collection.py +707 -0
- bead/resources/template_generation.py +349 -0
- bead/simulation/__init__.py +29 -0
- bead/simulation/annotators/__init__.py +15 -0
- bead/simulation/annotators/base.py +175 -0
- bead/simulation/annotators/distance_based.py +135 -0
- bead/simulation/annotators/lm_based.py +114 -0
- bead/simulation/annotators/oracle.py +182 -0
- bead/simulation/annotators/random.py +181 -0
- bead/simulation/dsl_extension/__init__.py +3 -0
- bead/simulation/noise_models/__init__.py +13 -0
- bead/simulation/noise_models/base.py +42 -0
- bead/simulation/noise_models/random_noise.py +82 -0
- bead/simulation/noise_models/systematic.py +132 -0
- bead/simulation/noise_models/temperature.py +86 -0
- bead/simulation/runner.py +144 -0
- bead/simulation/strategies/__init__.py +23 -0
- bead/simulation/strategies/base.py +123 -0
- bead/simulation/strategies/binary.py +103 -0
- bead/simulation/strategies/categorical.py +123 -0
- bead/simulation/strategies/cloze.py +224 -0
- bead/simulation/strategies/forced_choice.py +127 -0
- bead/simulation/strategies/free_text.py +105 -0
- bead/simulation/strategies/magnitude.py +116 -0
- bead/simulation/strategies/multi_select.py +129 -0
- bead/simulation/strategies/ordinal_scale.py +131 -0
- bead/templates/__init__.py +27 -0
- bead/templates/adapters/__init__.py +17 -0
- bead/templates/adapters/base.py +128 -0
- bead/templates/adapters/cache.py +178 -0
- bead/templates/adapters/huggingface.py +312 -0
- bead/templates/combinatorics.py +103 -0
- bead/templates/filler.py +605 -0
- bead/templates/renderers.py +177 -0
- bead/templates/resolver.py +178 -0
- bead/templates/strategies.py +1806 -0
- bead/templates/streaming.py +195 -0
- bead-0.1.0.dist-info/METADATA +212 -0
- bead-0.1.0.dist-info/RECORD +231 -0
- bead-0.1.0.dist-info/WHEEL +4 -0
- bead-0.1.0.dist-info/entry_points.txt +2 -0
- bead-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for randomizer.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests the trial randomization and constraint checking functions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
type Trial,
|
|
10
|
+
type TrialMetadata,
|
|
11
|
+
checkMinDistance,
|
|
12
|
+
checkNoAdjacent,
|
|
13
|
+
checkPrecedence,
|
|
14
|
+
getPropertyValue,
|
|
15
|
+
shuffle,
|
|
16
|
+
} from "./randomizer.js";
|
|
17
|
+
|
|
18
|
+
describe("shuffle (Fisher-Yates)", () => {
|
|
19
|
+
test("shuffles array in place", () => {
|
|
20
|
+
const array = [1, 2, 3, 4, 5];
|
|
21
|
+
const rng = (): number => 0.5; // Deterministic RNG
|
|
22
|
+
|
|
23
|
+
shuffle(array, rng);
|
|
24
|
+
|
|
25
|
+
expect(array).toHaveLength(5);
|
|
26
|
+
expect(array).toContain(1);
|
|
27
|
+
expect(array).toContain(2);
|
|
28
|
+
expect(array).toContain(3);
|
|
29
|
+
expect(array).toContain(4);
|
|
30
|
+
expect(array).toContain(5);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("produces different orders with different seeds", () => {
|
|
34
|
+
const array1 = [1, 2, 3, 4, 5];
|
|
35
|
+
const array2 = [1, 2, 3, 4, 5];
|
|
36
|
+
|
|
37
|
+
// Use simple counter-based RNGs with different starting points
|
|
38
|
+
let counter1 = 0;
|
|
39
|
+
const rng1 = (): number => {
|
|
40
|
+
counter1++;
|
|
41
|
+
return (counter1 * 0.1) % 1.0;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
let counter2 = 0;
|
|
45
|
+
const rng2 = (): number => {
|
|
46
|
+
counter2++;
|
|
47
|
+
return (counter2 * 0.7) % 1.0;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
shuffle(array1, rng1);
|
|
51
|
+
shuffle(array2, rng2);
|
|
52
|
+
|
|
53
|
+
// Arrays should be different with different seeds
|
|
54
|
+
const isDifferent = array1.some((val, idx) => val !== array2[idx]);
|
|
55
|
+
expect(isDifferent).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("handles empty array", () => {
|
|
59
|
+
const array: number[] = [];
|
|
60
|
+
const rng = (): number => 0.5;
|
|
61
|
+
|
|
62
|
+
expect(() => shuffle(array, rng)).not.toThrow();
|
|
63
|
+
expect(array).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("handles single element array", () => {
|
|
67
|
+
const array = [42];
|
|
68
|
+
const rng = (): number => 0.5;
|
|
69
|
+
|
|
70
|
+
shuffle(array, rng);
|
|
71
|
+
|
|
72
|
+
expect(array).toEqual([42]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("getPropertyValue", () => {
|
|
77
|
+
test("retrieves top-level property", () => {
|
|
78
|
+
const obj = { name: "test", value: 42 };
|
|
79
|
+
expect(getPropertyValue(obj, "name")).toBe("test");
|
|
80
|
+
expect(getPropertyValue(obj, "value")).toBe(42);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("retrieves nested property", () => {
|
|
84
|
+
const obj = {
|
|
85
|
+
item_metadata: {
|
|
86
|
+
condition: "A",
|
|
87
|
+
nested: {
|
|
88
|
+
deep: "value",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
expect(getPropertyValue(obj, "item_metadata.condition")).toBe("A");
|
|
94
|
+
expect(getPropertyValue(obj, "item_metadata.nested.deep")).toBe("value");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("returns undefined for missing property", () => {
|
|
98
|
+
const obj = { name: "test" };
|
|
99
|
+
expect(getPropertyValue(obj, "missing")).toBeUndefined();
|
|
100
|
+
expect(getPropertyValue(obj, "nested.missing")).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("handles null and undefined safely", () => {
|
|
104
|
+
expect(getPropertyValue(undefined, "path")).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("checkPrecedence", () => {
|
|
109
|
+
test("returns true when precedence satisfied", () => {
|
|
110
|
+
const trials: Trial[] = [{ item_id: "item1" }, { item_id: "item2" }, { item_id: "item3" }];
|
|
111
|
+
const pairs: [string, string][] = [
|
|
112
|
+
["item1", "item3"],
|
|
113
|
+
["item2", "item3"],
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
expect(checkPrecedence(trials, pairs)).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("returns false when precedence violated", () => {
|
|
120
|
+
const trials: Trial[] = [{ item_id: "item3" }, { item_id: "item1" }, { item_id: "item2" }];
|
|
121
|
+
const pairs: [string, string][] = [["item1", "item3"]]; // item1 should come before item3
|
|
122
|
+
|
|
123
|
+
expect(checkPrecedence(trials, pairs)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("handles missing items gracefully", () => {
|
|
127
|
+
const trials: Trial[] = [{ item_id: "item1" }, { item_id: "item2" }];
|
|
128
|
+
const pairs: [string, string][] = [["item1", "item99"]]; // item99 not in trials
|
|
129
|
+
|
|
130
|
+
expect(checkPrecedence(trials, pairs)).toBe(true); // Ignored
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("handles empty pairs array", () => {
|
|
134
|
+
const trials: Trial[] = [{ item_id: "item1" }];
|
|
135
|
+
const pairs: [string, string][] = [];
|
|
136
|
+
|
|
137
|
+
expect(checkPrecedence(trials, pairs)).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("checkNoAdjacent", () => {
|
|
142
|
+
test("returns true when no adjacent items have same value", () => {
|
|
143
|
+
const trials: Trial[] = [{ item_id: "item1" }, { item_id: "item2" }, { item_id: "item3" }];
|
|
144
|
+
const metadata: TrialMetadata = {
|
|
145
|
+
item1: { condition: "A" },
|
|
146
|
+
item2: { condition: "B" },
|
|
147
|
+
item3: { condition: "A" },
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
expect(checkNoAdjacent(trials, "condition", metadata)).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("returns false when adjacent items have same value", () => {
|
|
154
|
+
const trials: Trial[] = [{ item_id: "item1" }, { item_id: "item2" }, { item_id: "item3" }];
|
|
155
|
+
const metadata: TrialMetadata = {
|
|
156
|
+
item1: { condition: "A" },
|
|
157
|
+
item2: { condition: "A" }, // Same as item1
|
|
158
|
+
item3: { condition: "B" },
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
expect(checkNoAdjacent(trials, "condition", metadata)).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("handles nested property paths", () => {
|
|
165
|
+
const trials: Trial[] = [{ item_id: "item1" }, { item_id: "item2" }];
|
|
166
|
+
const metadata: TrialMetadata = {
|
|
167
|
+
item1: { item_metadata: { condition: "A" } },
|
|
168
|
+
item2: { item_metadata: { condition: "B" } },
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
expect(checkNoAdjacent(trials, "item_metadata.condition", metadata)).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("ignores undefined values", () => {
|
|
175
|
+
const trials: Trial[] = [{ item_id: "item1" }, { item_id: "item2" }];
|
|
176
|
+
const metadata: TrialMetadata = {
|
|
177
|
+
item1: { condition: "A" },
|
|
178
|
+
item2: {}, // Missing condition
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
expect(checkNoAdjacent(trials, "condition", metadata)).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("checkMinDistance", () => {
|
|
186
|
+
test("returns true when minimum distance satisfied", () => {
|
|
187
|
+
const trials: Trial[] = [
|
|
188
|
+
{ item_id: "item1" }, // pos 0
|
|
189
|
+
{ item_id: "item2" }, // pos 1
|
|
190
|
+
{ item_id: "item3" }, // pos 2
|
|
191
|
+
{ item_id: "item4" }, // pos 3
|
|
192
|
+
{ item_id: "item5" }, // pos 4
|
|
193
|
+
];
|
|
194
|
+
const metadata: TrialMetadata = {
|
|
195
|
+
item1: { condition: "A" },
|
|
196
|
+
item2: { condition: "B" },
|
|
197
|
+
item3: { condition: "C" },
|
|
198
|
+
item4: { condition: "B" },
|
|
199
|
+
item5: { condition: "A" },
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Distance between A's: 4 - 0 - 1 = 3
|
|
203
|
+
// Distance between B's: 3 - 1 - 1 = 1
|
|
204
|
+
expect(checkMinDistance(trials, "condition", 1, metadata)).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("returns false when minimum distance violated", () => {
|
|
208
|
+
const trials: Trial[] = [
|
|
209
|
+
{ item_id: "item1" }, // pos 0, A
|
|
210
|
+
{ item_id: "item2" }, // pos 1, B
|
|
211
|
+
{ item_id: "item3" }, // pos 2, A
|
|
212
|
+
];
|
|
213
|
+
const metadata: TrialMetadata = {
|
|
214
|
+
item1: { condition: "A" },
|
|
215
|
+
item2: { condition: "B" },
|
|
216
|
+
item3: { condition: "A" },
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Distance between A's: 2 - 0 - 1 = 1 (need min 2)
|
|
220
|
+
expect(checkMinDistance(trials, "condition", 2, metadata)).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("handles single occurrence (no distance constraint)", () => {
|
|
224
|
+
const trials: Trial[] = [{ item_id: "item1" }, { item_id: "item2" }];
|
|
225
|
+
const metadata: TrialMetadata = {
|
|
226
|
+
item1: { condition: "A" },
|
|
227
|
+
item2: { condition: "B" },
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
expect(checkMinDistance(trials, "condition", 10, metadata)).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trial randomization with constraint enforcement.
|
|
3
|
+
*
|
|
4
|
+
* Provides functions for shuffling and validating trial order
|
|
5
|
+
* against ordering constraints (precedence, no-adjacent, min-distance).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Trial structure with required item_id
|
|
9
|
+
export interface Trial {
|
|
10
|
+
item_id: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Ordering constraint specification
|
|
15
|
+
export interface OrderingConstraint {
|
|
16
|
+
precedence_pairs?: [string, string][];
|
|
17
|
+
no_adjacent_property?: string;
|
|
18
|
+
min_distance?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Randomizer configuration
|
|
22
|
+
export interface RandomizerConfig {
|
|
23
|
+
hasPrecedence: boolean;
|
|
24
|
+
hasNoAdjacent: boolean;
|
|
25
|
+
hasMinDistance: boolean;
|
|
26
|
+
hasBlocking: boolean;
|
|
27
|
+
hasPractice: boolean;
|
|
28
|
+
blockProperty?: string;
|
|
29
|
+
practiceProperty?: string;
|
|
30
|
+
randomizeWithinBlocks?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Trial metadata record
|
|
34
|
+
export type TrialMetadata = Record<string, Record<string, unknown>>;
|
|
35
|
+
|
|
36
|
+
// Seeded random number generator type
|
|
37
|
+
export type SeededRNG = () => number;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Shuffle array in place using Fisher-Yates algorithm.
|
|
41
|
+
*/
|
|
42
|
+
export function shuffle<T>(array: T[], rng: SeededRNG): void {
|
|
43
|
+
for (let i = array.length - 1; i > 0; i--) {
|
|
44
|
+
const j = Math.floor(rng() * (i + 1));
|
|
45
|
+
const temp = array[i];
|
|
46
|
+
const swapVal = array[j];
|
|
47
|
+
if (temp !== undefined && swapVal !== undefined) {
|
|
48
|
+
array[i] = swapVal;
|
|
49
|
+
array[j] = temp;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get property value from nested object using dot notation.
|
|
56
|
+
*/
|
|
57
|
+
export function getPropertyValue(obj: Record<string, unknown> | undefined, path: string): unknown {
|
|
58
|
+
if (obj === undefined || obj === null) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const parts = path.split(".");
|
|
63
|
+
let current: unknown = obj;
|
|
64
|
+
|
|
65
|
+
for (const part of parts) {
|
|
66
|
+
if (current === undefined || current === null) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
if (typeof current === "object" && current !== null) {
|
|
70
|
+
current = (current as Record<string, unknown>)[part];
|
|
71
|
+
} else {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return current;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if trial order satisfies precedence constraints.
|
|
81
|
+
* Item A must appear before item B for each pair.
|
|
82
|
+
*/
|
|
83
|
+
export function checkPrecedence(trials: Trial[], pairs: [string, string][]): boolean {
|
|
84
|
+
const positions: Record<string, number> = {};
|
|
85
|
+
trials.forEach((trial, idx) => {
|
|
86
|
+
positions[trial.item_id] = idx;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
for (const [itemA, itemB] of pairs) {
|
|
90
|
+
const posA = positions[itemA];
|
|
91
|
+
const posB = positions[itemB];
|
|
92
|
+
if (posA !== undefined && posB !== undefined) {
|
|
93
|
+
if (posA >= posB) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check if no adjacent trials have the same property value.
|
|
103
|
+
*/
|
|
104
|
+
export function checkNoAdjacent(
|
|
105
|
+
trials: Trial[],
|
|
106
|
+
property: string,
|
|
107
|
+
metadata: TrialMetadata,
|
|
108
|
+
): boolean {
|
|
109
|
+
for (let i = 0; i < trials.length - 1; i++) {
|
|
110
|
+
const trialA = trials[i];
|
|
111
|
+
const trialB = trials[i + 1];
|
|
112
|
+
if (!trialA || !trialB) continue;
|
|
113
|
+
|
|
114
|
+
const metaA = metadata[trialA.item_id];
|
|
115
|
+
const metaB = metadata[trialB.item_id];
|
|
116
|
+
|
|
117
|
+
const valueA = getPropertyValue(metaA, property);
|
|
118
|
+
const valueB = getPropertyValue(metaB, property);
|
|
119
|
+
|
|
120
|
+
if (valueA !== undefined && valueB !== undefined && valueA === valueB) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check if minimum distance constraint is satisfied.
|
|
129
|
+
* Items with the same property value must be at least minDist apart.
|
|
130
|
+
*/
|
|
131
|
+
export function checkMinDistance(
|
|
132
|
+
trials: Trial[],
|
|
133
|
+
property: string,
|
|
134
|
+
minDist: number,
|
|
135
|
+
metadata: TrialMetadata,
|
|
136
|
+
): boolean {
|
|
137
|
+
const valuePositions: Record<string, number[]> = {};
|
|
138
|
+
|
|
139
|
+
trials.forEach((trial, idx) => {
|
|
140
|
+
const meta = metadata[trial.item_id];
|
|
141
|
+
const value = getPropertyValue(meta, property);
|
|
142
|
+
if (value !== undefined) {
|
|
143
|
+
const key = String(value);
|
|
144
|
+
if (!valuePositions[key]) {
|
|
145
|
+
valuePositions[key] = [];
|
|
146
|
+
}
|
|
147
|
+
valuePositions[key]?.push(idx);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
for (const positions of Object.values(valuePositions)) {
|
|
152
|
+
for (let i = 0; i < positions.length - 1; i++) {
|
|
153
|
+
const posA = positions[i];
|
|
154
|
+
const posB = positions[i + 1];
|
|
155
|
+
if (posA !== undefined && posB !== undefined) {
|
|
156
|
+
const distance = posB - posA - 1;
|
|
157
|
+
if (distance < minDist) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check if trial order satisfies all constraints.
|
|
168
|
+
*/
|
|
169
|
+
export function checkAllConstraints(
|
|
170
|
+
trials: Trial[],
|
|
171
|
+
constraints: OrderingConstraint[],
|
|
172
|
+
metadata: TrialMetadata,
|
|
173
|
+
config: RandomizerConfig,
|
|
174
|
+
): boolean {
|
|
175
|
+
for (const constraint of constraints) {
|
|
176
|
+
// check precedence constraints
|
|
177
|
+
if (config.hasPrecedence && constraint.precedence_pairs) {
|
|
178
|
+
if (constraint.precedence_pairs.length > 0) {
|
|
179
|
+
if (!checkPrecedence(trials, constraint.precedence_pairs)) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// check no-adjacent constraints
|
|
186
|
+
if (config.hasNoAdjacent && constraint.no_adjacent_property) {
|
|
187
|
+
if (!checkNoAdjacent(trials, constraint.no_adjacent_property, metadata)) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// check minimum distance constraints
|
|
193
|
+
if (config.hasMinDistance && constraint.min_distance !== undefined) {
|
|
194
|
+
if (constraint.no_adjacent_property) {
|
|
195
|
+
if (
|
|
196
|
+
!checkMinDistance(
|
|
197
|
+
trials,
|
|
198
|
+
constraint.no_adjacent_property,
|
|
199
|
+
constraint.min_distance,
|
|
200
|
+
metadata,
|
|
201
|
+
)
|
|
202
|
+
) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Separate practice and main trials.
|
|
214
|
+
*/
|
|
215
|
+
export function separatePracticeTrials(
|
|
216
|
+
trials: Trial[],
|
|
217
|
+
metadata: TrialMetadata,
|
|
218
|
+
practiceProperty: string,
|
|
219
|
+
): { practiceTrials: Trial[]; mainTrials: Trial[] } {
|
|
220
|
+
const practiceTrials: Trial[] = [];
|
|
221
|
+
const mainTrials: Trial[] = [];
|
|
222
|
+
|
|
223
|
+
for (const trial of trials) {
|
|
224
|
+
const meta = metadata[trial.item_id];
|
|
225
|
+
const value = getPropertyValue(meta, practiceProperty);
|
|
226
|
+
if (value === true) {
|
|
227
|
+
practiceTrials.push(trial);
|
|
228
|
+
} else {
|
|
229
|
+
mainTrials.push(trial);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { practiceTrials, mainTrials };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Group trials by block property.
|
|
238
|
+
*/
|
|
239
|
+
export function groupByBlock(
|
|
240
|
+
trials: Trial[],
|
|
241
|
+
metadata: TrialMetadata,
|
|
242
|
+
blockProperty: string,
|
|
243
|
+
): Record<string, Trial[]> {
|
|
244
|
+
const blocks: Record<string, Trial[]> = {};
|
|
245
|
+
|
|
246
|
+
for (const trial of trials) {
|
|
247
|
+
const meta = metadata[trial.item_id];
|
|
248
|
+
const blockValue = getPropertyValue(meta, blockProperty);
|
|
249
|
+
const blockKey = blockValue !== undefined ? String(blockValue) : "__undefined__";
|
|
250
|
+
|
|
251
|
+
if (!blocks[blockKey]) {
|
|
252
|
+
blocks[blockKey] = [];
|
|
253
|
+
}
|
|
254
|
+
blocks[blockKey]?.push(trial);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return blocks;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Randomize trials with blocking.
|
|
262
|
+
*/
|
|
263
|
+
export function randomizeWithBlocking(
|
|
264
|
+
mainTrials: Trial[],
|
|
265
|
+
metadata: TrialMetadata,
|
|
266
|
+
blockProperty: string,
|
|
267
|
+
randomizeWithinBlocks: boolean,
|
|
268
|
+
rng: SeededRNG,
|
|
269
|
+
): Trial[] {
|
|
270
|
+
const blocks = groupByBlock(mainTrials, metadata, blockProperty);
|
|
271
|
+
|
|
272
|
+
// randomize block order
|
|
273
|
+
const blockKeys = Object.keys(blocks);
|
|
274
|
+
shuffle(blockKeys, rng);
|
|
275
|
+
|
|
276
|
+
let randomizedMain: Trial[] = [];
|
|
277
|
+
for (const key of blockKeys) {
|
|
278
|
+
const blockTrials = blocks[key];
|
|
279
|
+
if (blockTrials) {
|
|
280
|
+
if (randomizeWithinBlocks) {
|
|
281
|
+
shuffle(blockTrials, rng);
|
|
282
|
+
}
|
|
283
|
+
randomizedMain = randomizedMain.concat(blockTrials);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return randomizedMain;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Randomize trials with rejection sampling (no blocking).
|
|
292
|
+
*/
|
|
293
|
+
export function randomizeWithRejectionSampling(
|
|
294
|
+
mainTrials: Trial[],
|
|
295
|
+
constraints: OrderingConstraint[],
|
|
296
|
+
metadata: TrialMetadata,
|
|
297
|
+
config: RandomizerConfig,
|
|
298
|
+
rng: SeededRNG,
|
|
299
|
+
maxAttempts = 1000,
|
|
300
|
+
): Trial[] {
|
|
301
|
+
let randomizedMain = [...mainTrials];
|
|
302
|
+
let lastAttempt = [...randomizedMain];
|
|
303
|
+
|
|
304
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
305
|
+
shuffle(randomizedMain, rng);
|
|
306
|
+
lastAttempt = [...randomizedMain];
|
|
307
|
+
|
|
308
|
+
if (checkAllConstraints(randomizedMain, constraints, metadata, config)) {
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (attempt === maxAttempts - 1) {
|
|
313
|
+
console.warn(
|
|
314
|
+
`Could not find constraint-satisfying order after ${maxAttempts} attempts. Using last attempt.`,
|
|
315
|
+
);
|
|
316
|
+
randomizedMain = lastAttempt;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return randomizedMain;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Create a randomizer function with the given configuration.
|
|
325
|
+
*/
|
|
326
|
+
export function createRandomizer(
|
|
327
|
+
metadata: TrialMetadata,
|
|
328
|
+
constraints: OrderingConstraint[],
|
|
329
|
+
config: RandomizerConfig,
|
|
330
|
+
): (trials: Trial[], rng: SeededRNG) => Trial[] {
|
|
331
|
+
return (trials: Trial[], rng: SeededRNG): Trial[] => {
|
|
332
|
+
// separate practice trials if configured
|
|
333
|
+
let practiceTrials: Trial[] = [];
|
|
334
|
+
let mainTrials: Trial[];
|
|
335
|
+
|
|
336
|
+
if (config.hasPractice && config.practiceProperty) {
|
|
337
|
+
const separated = separatePracticeTrials(trials, metadata, config.practiceProperty);
|
|
338
|
+
practiceTrials = separated.practiceTrials;
|
|
339
|
+
mainTrials = separated.mainTrials;
|
|
340
|
+
} else {
|
|
341
|
+
mainTrials = [...trials];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// randomize main trials
|
|
345
|
+
let randomizedMain: Trial[];
|
|
346
|
+
|
|
347
|
+
if (config.hasBlocking && config.blockProperty) {
|
|
348
|
+
randomizedMain = randomizeWithBlocking(
|
|
349
|
+
mainTrials,
|
|
350
|
+
metadata,
|
|
351
|
+
config.blockProperty,
|
|
352
|
+
config.randomizeWithinBlocks ?? true,
|
|
353
|
+
rng,
|
|
354
|
+
);
|
|
355
|
+
} else {
|
|
356
|
+
randomizedMain = randomizeWithRejectionSampling(
|
|
357
|
+
mainTrials,
|
|
358
|
+
constraints,
|
|
359
|
+
metadata,
|
|
360
|
+
config,
|
|
361
|
+
rng,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return practiceTrials.concat(randomizedMain);
|
|
366
|
+
};
|
|
367
|
+
}
|