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,299 @@
|
|
|
1
|
+
"""JavaScript randomizer code generator from OrderingConstraints.
|
|
2
|
+
|
|
3
|
+
This module converts Python OrderingConstraint models into JavaScript code
|
|
4
|
+
that performs constraint-aware trial randomization at jsPsych runtime. This
|
|
5
|
+
enables per-participant randomization while satisfying all ordering constraints.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
from jinja2 import Environment, FileSystemLoader
|
|
15
|
+
|
|
16
|
+
from bead.data.base import JsonValue
|
|
17
|
+
from bead.lists.constraints import OrderingConstraint
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_randomizer_function(
|
|
21
|
+
item_ids: list[UUID],
|
|
22
|
+
constraints: list[OrderingConstraint],
|
|
23
|
+
metadata: dict[UUID, dict[str, JsonValue]],
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Generate JavaScript code for constraint-aware trial randomization.
|
|
26
|
+
|
|
27
|
+
This function converts OrderingConstraints into JavaScript code that can
|
|
28
|
+
randomize trial order at runtime while satisfying all constraints. The
|
|
29
|
+
generated code uses seeded randomization for reproducibility and rejection
|
|
30
|
+
sampling to satisfy constraints.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
item_ids : list[UUID]
|
|
35
|
+
List of item IDs included in the experiment.
|
|
36
|
+
constraints : list[OrderingConstraint]
|
|
37
|
+
Ordering constraints to enforce.
|
|
38
|
+
metadata : dict[UUID, dict[str, JsonValue]]
|
|
39
|
+
Item metadata needed for constraint checking (keyed by item UUID).
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
str
|
|
44
|
+
JavaScript code implementing randomizeTrials() function.
|
|
45
|
+
|
|
46
|
+
Examples
|
|
47
|
+
--------
|
|
48
|
+
>>> from uuid import UUID
|
|
49
|
+
>>> item1 = UUID("12345678-1234-5678-1234-567812345678")
|
|
50
|
+
>>> item2 = UUID("87654321-4321-8765-4321-876543218765")
|
|
51
|
+
>>> constraint = OrderingConstraint(
|
|
52
|
+
... no_adjacent_property="item_metadata.condition"
|
|
53
|
+
... )
|
|
54
|
+
>>> metadata = {
|
|
55
|
+
... item1: {"condition": "A"},
|
|
56
|
+
... item2: {"condition": "B"}
|
|
57
|
+
... }
|
|
58
|
+
>>> js_code = generate_randomizer_function(
|
|
59
|
+
... [item1, item2],
|
|
60
|
+
... [constraint],
|
|
61
|
+
... metadata
|
|
62
|
+
... )
|
|
63
|
+
>>> "function randomizeTrials" in js_code
|
|
64
|
+
True
|
|
65
|
+
>>> "checkNoAdjacentConstraints" in js_code
|
|
66
|
+
True
|
|
67
|
+
"""
|
|
68
|
+
# prepare template context
|
|
69
|
+
context = _prepare_template_context(item_ids, constraints, metadata)
|
|
70
|
+
|
|
71
|
+
# load and render template
|
|
72
|
+
template_dir = Path(__file__).parent / "templates"
|
|
73
|
+
env = Environment(loader=FileSystemLoader(str(template_dir)))
|
|
74
|
+
template = env.get_template("randomizer.js.template")
|
|
75
|
+
|
|
76
|
+
return template.render(**context)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _prepare_template_context(
|
|
80
|
+
item_ids: list[UUID],
|
|
81
|
+
constraints: list[OrderingConstraint],
|
|
82
|
+
metadata: dict[UUID, dict[str, JsonValue]],
|
|
83
|
+
) -> dict[str, JsonValue]:
|
|
84
|
+
"""Prepare Jinja2 template context from constraints.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
item_ids : list[UUID]
|
|
89
|
+
Item IDs in the experiment.
|
|
90
|
+
constraints : list[OrderingConstraint]
|
|
91
|
+
Ordering constraints.
|
|
92
|
+
metadata : dict[UUID, dict[str, JsonValue]]
|
|
93
|
+
Item metadata.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
dict[str, JsonValue]
|
|
98
|
+
Template context for Jinja2 rendering.
|
|
99
|
+
"""
|
|
100
|
+
context: dict[str, JsonValue] = {
|
|
101
|
+
"metadata_json": _serialize_metadata(metadata),
|
|
102
|
+
"has_practice_items": False,
|
|
103
|
+
"practice_property": "",
|
|
104
|
+
"has_blocking": False,
|
|
105
|
+
"block_property": "",
|
|
106
|
+
"randomize_within_blocks": True,
|
|
107
|
+
"has_precedence": False,
|
|
108
|
+
"precedence_pairs_json": "[]",
|
|
109
|
+
"has_no_adjacent": False,
|
|
110
|
+
"no_adjacent_property": "",
|
|
111
|
+
"has_distance": False,
|
|
112
|
+
"distance_constraints_json": "[]",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# combine all constraints (multiple OrderingConstraints can be active)
|
|
116
|
+
for constraint in constraints:
|
|
117
|
+
# practice items
|
|
118
|
+
if constraint.practice_item_property:
|
|
119
|
+
context["has_practice_items"] = True
|
|
120
|
+
# extract property name from path
|
|
121
|
+
# (e.g., "item_metadata.is_practice" -> "is_practice")
|
|
122
|
+
context["practice_property"] = _extract_property_name(
|
|
123
|
+
constraint.practice_item_property
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# blocking
|
|
127
|
+
if constraint.block_by_property:
|
|
128
|
+
context["has_blocking"] = True
|
|
129
|
+
context["block_property"] = _extract_property_name(
|
|
130
|
+
constraint.block_by_property
|
|
131
|
+
)
|
|
132
|
+
context["randomize_within_blocks"] = constraint.randomize_within_blocks
|
|
133
|
+
|
|
134
|
+
# precedence
|
|
135
|
+
if constraint.precedence_pairs:
|
|
136
|
+
context["has_precedence"] = True
|
|
137
|
+
# convert UUID pairs to string pairs for JSON
|
|
138
|
+
pairs = [[str(a), str(b)] for a, b in constraint.precedence_pairs]
|
|
139
|
+
context["precedence_pairs_json"] = json.dumps(pairs)
|
|
140
|
+
|
|
141
|
+
# no-adjacency
|
|
142
|
+
if constraint.no_adjacent_property:
|
|
143
|
+
context["has_no_adjacent"] = True
|
|
144
|
+
# extract property name since metadata is already extracted
|
|
145
|
+
context["no_adjacent_property"] = _extract_property_name(
|
|
146
|
+
constraint.no_adjacent_property
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# distance constraints
|
|
150
|
+
if constraint.min_distance or constraint.max_distance:
|
|
151
|
+
context["has_distance"] = True
|
|
152
|
+
# generate distance constraints for all item pairs
|
|
153
|
+
distance_constraints = _generate_distance_constraints(
|
|
154
|
+
item_ids, constraint, metadata
|
|
155
|
+
)
|
|
156
|
+
context["distance_constraints_json"] = json.dumps(distance_constraints)
|
|
157
|
+
|
|
158
|
+
return context
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _serialize_metadata(metadata: dict[UUID, dict[str, JsonValue]]) -> str:
|
|
162
|
+
"""Serialize metadata dictionary to JSON.
|
|
163
|
+
|
|
164
|
+
Converts UUID keys to strings for JSON serialization.
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
metadata : dict[UUID, dict[str, JsonValue]]
|
|
169
|
+
Item metadata with UUID keys.
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
str
|
|
174
|
+
JSON string of metadata.
|
|
175
|
+
"""
|
|
176
|
+
# convert UUID keys to strings
|
|
177
|
+
serializable = {str(k): v for k, v in metadata.items()}
|
|
178
|
+
return json.dumps(serializable, indent=2)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _extract_property_name(property_path: str) -> str:
|
|
182
|
+
"""Extract final property name from dot-notation path.
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
property_path : str
|
|
187
|
+
Dot-notation property path (e.g., "item_metadata.is_practice").
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
str
|
|
192
|
+
Final property name (e.g., "is_practice").
|
|
193
|
+
|
|
194
|
+
Examples
|
|
195
|
+
--------
|
|
196
|
+
>>> _extract_property_name("item_metadata.is_practice")
|
|
197
|
+
'is_practice'
|
|
198
|
+
>>> _extract_property_name("condition")
|
|
199
|
+
'condition'
|
|
200
|
+
"""
|
|
201
|
+
parts = property_path.split(".")
|
|
202
|
+
return parts[-1]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _generate_distance_constraints(
|
|
206
|
+
item_ids: list[UUID],
|
|
207
|
+
constraint: OrderingConstraint,
|
|
208
|
+
metadata: dict[UUID, dict[str, JsonValue]],
|
|
209
|
+
) -> list[dict[str, str | int | None]]:
|
|
210
|
+
"""Generate distance constraints for all relevant item pairs.
|
|
211
|
+
|
|
212
|
+
Distance constraints are applied to items that share the same value
|
|
213
|
+
for the no_adjacent_property (if specified).
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
item_ids : list[UUID]
|
|
218
|
+
Item IDs in the experiment.
|
|
219
|
+
constraint : OrderingConstraint
|
|
220
|
+
Ordering constraint with distance specifications.
|
|
221
|
+
metadata : dict[UUID, dict[str, JsonValue]]
|
|
222
|
+
Item metadata.
|
|
223
|
+
|
|
224
|
+
Returns
|
|
225
|
+
-------
|
|
226
|
+
list[dict[str, str | int | None]]
|
|
227
|
+
List of distance constraint specifications.
|
|
228
|
+
"""
|
|
229
|
+
distance_constraints: list[dict[str, str | int | None]] = []
|
|
230
|
+
|
|
231
|
+
# group items by property value if no_adjacent_property is set
|
|
232
|
+
if constraint.no_adjacent_property:
|
|
233
|
+
property_path = constraint.no_adjacent_property
|
|
234
|
+
# extract just the property name from the path
|
|
235
|
+
# (e.g., "condition" from "item_metadata.condition")
|
|
236
|
+
# because metadata is already extracted from items
|
|
237
|
+
property_name = _extract_property_name(property_path)
|
|
238
|
+
|
|
239
|
+
# group items by property value
|
|
240
|
+
groups: dict[JsonValue, list[UUID]] = {}
|
|
241
|
+
for item_id in item_ids:
|
|
242
|
+
item_meta = metadata.get(item_id, {})
|
|
243
|
+
value = item_meta.get(property_name)
|
|
244
|
+
|
|
245
|
+
if value is not None:
|
|
246
|
+
if value not in groups:
|
|
247
|
+
groups[value] = []
|
|
248
|
+
groups[value].append(item_id)
|
|
249
|
+
|
|
250
|
+
# create pairwise distance constraints within each group
|
|
251
|
+
for _value, item_group in groups.items():
|
|
252
|
+
if len(item_group) > 1:
|
|
253
|
+
# create constraints for all pairs in this group
|
|
254
|
+
for i, item1 in enumerate(item_group):
|
|
255
|
+
for item2 in item_group[i + 1 :]:
|
|
256
|
+
distance_constraints.append(
|
|
257
|
+
{
|
|
258
|
+
"item1_id": str(item1),
|
|
259
|
+
"item2_id": str(item2),
|
|
260
|
+
"min_distance": constraint.min_distance,
|
|
261
|
+
"max_distance": constraint.max_distance,
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return distance_constraints
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _get_nested_property(obj: dict[str, JsonValue], path: str) -> JsonValue: # pyright: ignore[reportUnusedFunction]
|
|
269
|
+
"""Get nested property from dictionary using dot notation.
|
|
270
|
+
|
|
271
|
+
Parameters
|
|
272
|
+
----------
|
|
273
|
+
obj : dict[str, JsonValue]
|
|
274
|
+
Object to query.
|
|
275
|
+
path : str
|
|
276
|
+
Property path (e.g., "item_metadata.condition").
|
|
277
|
+
|
|
278
|
+
Returns
|
|
279
|
+
-------
|
|
280
|
+
JsonValue
|
|
281
|
+
Property value or None if not found.
|
|
282
|
+
|
|
283
|
+
Examples
|
|
284
|
+
--------
|
|
285
|
+
>>> obj = {"item_metadata": {"condition": "A"}}
|
|
286
|
+
>>> _get_nested_property(obj, "item_metadata.condition")
|
|
287
|
+
'A'
|
|
288
|
+
>>> _get_nested_property(obj, "missing.path") is None
|
|
289
|
+
True
|
|
290
|
+
"""
|
|
291
|
+
parts = path.split(".")
|
|
292
|
+
current = obj
|
|
293
|
+
|
|
294
|
+
for part in parts:
|
|
295
|
+
if not isinstance(current, dict) or part not in current:
|
|
296
|
+
return None
|
|
297
|
+
current = current[part]
|
|
298
|
+
|
|
299
|
+
return current
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for list-distributor.ts helper functions.
|
|
3
|
+
*
|
|
4
|
+
* Tests the pure functions that can be tested without JATOS.
|
|
5
|
+
* The main ListDistributor class requires JATOS batch sessions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
|
|
10
|
+
// Helper functions copied from list-distributor.ts for isolated testing.
|
|
11
|
+
// These are internal functions not exported from the module.
|
|
12
|
+
|
|
13
|
+
function generateBalancedLatinSquare(n: number): number[][] {
|
|
14
|
+
const square: number[][] = [];
|
|
15
|
+
for (let i = 0; i < n; i++) {
|
|
16
|
+
const row: number[] = [];
|
|
17
|
+
for (let j = 0; j < n; j++) {
|
|
18
|
+
if (i % 2 === 0) {
|
|
19
|
+
row.push((Math.floor(i / 2) + j) % n);
|
|
20
|
+
} else {
|
|
21
|
+
row.push((Math.floor(i / 2) + n - j) % n);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
square.push(row);
|
|
25
|
+
}
|
|
26
|
+
return square;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function shuffleArray<T>(array: T[]): void {
|
|
30
|
+
for (let i = array.length - 1; i > 0; i--) {
|
|
31
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
32
|
+
const temp = array[i];
|
|
33
|
+
const swapVal = array[j];
|
|
34
|
+
if (temp !== undefined && swapVal !== undefined) {
|
|
35
|
+
array[i] = swapVal;
|
|
36
|
+
array[j] = temp;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ExperimentList {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
list_number: number;
|
|
45
|
+
list_metadata?: Record<string, unknown>;
|
|
46
|
+
item_refs: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface QueueEntry {
|
|
50
|
+
list_index: number;
|
|
51
|
+
list_id: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface DistributionConfig {
|
|
55
|
+
strategy_type: string;
|
|
56
|
+
strategy_config?: {
|
|
57
|
+
factors?: string[];
|
|
58
|
+
weight_expression?: string;
|
|
59
|
+
normalize_weights?: boolean;
|
|
60
|
+
participants_per_list?: number;
|
|
61
|
+
allow_overflow?: boolean;
|
|
62
|
+
filter_expression?: string;
|
|
63
|
+
rank_expression?: string;
|
|
64
|
+
rank_ascending?: boolean;
|
|
65
|
+
};
|
|
66
|
+
max_participants?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function initializeRandom(
|
|
70
|
+
_config: DistributionConfig,
|
|
71
|
+
lists: ExperimentList[],
|
|
72
|
+
maxParticipants: number,
|
|
73
|
+
): QueueEntry[] {
|
|
74
|
+
const queue: QueueEntry[] = [];
|
|
75
|
+
const perList = Math.ceil(maxParticipants / lists.length);
|
|
76
|
+
for (let i = 0; i < lists.length; i++) {
|
|
77
|
+
const list = lists[i];
|
|
78
|
+
if (list) {
|
|
79
|
+
for (let j = 0; j < perList; j++) {
|
|
80
|
+
queue.push({ list_index: i, list_id: list.id });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
shuffleArray(queue);
|
|
85
|
+
return queue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function initializeSequential(
|
|
89
|
+
_config: DistributionConfig,
|
|
90
|
+
lists: ExperimentList[],
|
|
91
|
+
maxParticipants: number,
|
|
92
|
+
): QueueEntry[] {
|
|
93
|
+
const queue: QueueEntry[] = [];
|
|
94
|
+
for (let i = 0; i < maxParticipants; i++) {
|
|
95
|
+
const listIndex = i % lists.length;
|
|
96
|
+
const list = lists[listIndex];
|
|
97
|
+
if (list) {
|
|
98
|
+
queue.push({ list_index: listIndex, list_id: list.id });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return queue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function initializeLatinSquare(
|
|
105
|
+
_config: DistributionConfig,
|
|
106
|
+
lists: ExperimentList[],
|
|
107
|
+
): { queue: QueueEntry[]; matrix: number[][] } {
|
|
108
|
+
const matrix = generateBalancedLatinSquare(lists.length);
|
|
109
|
+
const queue: QueueEntry[] = [];
|
|
110
|
+
|
|
111
|
+
for (let row = 0; row < matrix.length; row++) {
|
|
112
|
+
const matrixRow = matrix[row];
|
|
113
|
+
if (matrixRow) {
|
|
114
|
+
for (let col = 0; col < matrixRow.length; col++) {
|
|
115
|
+
const listIndex = matrixRow[col];
|
|
116
|
+
if (listIndex !== undefined) {
|
|
117
|
+
const list = lists[listIndex];
|
|
118
|
+
if (list) {
|
|
119
|
+
queue.push({ list_index: listIndex, list_id: list.id });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { queue, matrix };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function initializeQuotaBased(
|
|
130
|
+
config: DistributionConfig,
|
|
131
|
+
lists: ExperimentList[],
|
|
132
|
+
): { queue: QueueEntry[]; quotas: Record<number, number> } {
|
|
133
|
+
const quota = config.strategy_config?.participants_per_list ?? 10;
|
|
134
|
+
const quotas: Record<number, number> = {};
|
|
135
|
+
const queue: QueueEntry[] = [];
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < lists.length; i++) {
|
|
138
|
+
quotas[i] = quota;
|
|
139
|
+
const list = lists[i];
|
|
140
|
+
if (list) {
|
|
141
|
+
for (let j = 0; j < quota; j++) {
|
|
142
|
+
queue.push({ list_index: i, list_id: list.id });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
shuffleArray(queue);
|
|
148
|
+
return { queue, quotas };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe("generateBalancedLatinSquare", () => {
|
|
152
|
+
test("generates correct 4x4 balanced Latin square", () => {
|
|
153
|
+
const square = generateBalancedLatinSquare(4);
|
|
154
|
+
|
|
155
|
+
expect(square).toHaveLength(4);
|
|
156
|
+
expect(square[0]).toHaveLength(4);
|
|
157
|
+
|
|
158
|
+
// Check each row contains 0,1,2,3
|
|
159
|
+
for (const row of square) {
|
|
160
|
+
const sorted = [...row].sort();
|
|
161
|
+
expect(sorted).toEqual([0, 1, 2, 3]);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("generates correct 2x2 balanced Latin square", () => {
|
|
166
|
+
const square = generateBalancedLatinSquare(2);
|
|
167
|
+
|
|
168
|
+
expect(square).toHaveLength(2);
|
|
169
|
+
// Row 0 (even): (floor(0/2) + j) % 2 = (0 + j) % 2
|
|
170
|
+
expect(square[0]).toEqual([0, 1]);
|
|
171
|
+
// Row 1 (odd): (floor(1/2) + 2 - j) % 2 = (0 + 2 - j) % 2
|
|
172
|
+
expect(square[1]).toEqual([0, 1]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("generates square with balanced counterbalancing", () => {
|
|
176
|
+
const square = generateBalancedLatinSquare(4);
|
|
177
|
+
|
|
178
|
+
// Verify first column follows the algorithm pattern
|
|
179
|
+
// i=0 (even): (floor(0/2) + 0) % 4 = 0
|
|
180
|
+
expect(square[0]?.[0]).toBe(0);
|
|
181
|
+
// i=1 (odd): (floor(1/2) + 4 - 0) % 4 = (0 + 4) % 4 = 0
|
|
182
|
+
expect(square[1]?.[0]).toBe(0);
|
|
183
|
+
// i=2 (even): (floor(2/2) + 0) % 4 = 1
|
|
184
|
+
expect(square[2]?.[0]).toBe(1);
|
|
185
|
+
// i=3 (odd): (floor(3/2) + 4 - 0) % 4 = (1 + 4) % 4 = 1
|
|
186
|
+
expect(square[3]?.[0]).toBe(1);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("initializeRandom", () => {
|
|
191
|
+
test("generates queue with equal entries per list", () => {
|
|
192
|
+
const lists: ExperimentList[] = [
|
|
193
|
+
{ id: "list_1", name: "list_1", list_number: 0, item_refs: [] },
|
|
194
|
+
{ id: "list_2", name: "list_2", list_number: 1, item_refs: [] },
|
|
195
|
+
{ id: "list_3", name: "list_3", list_number: 2, item_refs: [] },
|
|
196
|
+
];
|
|
197
|
+
const config: DistributionConfig = { strategy_type: "random" };
|
|
198
|
+
const maxParticipants = 30;
|
|
199
|
+
|
|
200
|
+
const queue = initializeRandom(config, lists, maxParticipants);
|
|
201
|
+
|
|
202
|
+
expect(queue.length).toBe(30);
|
|
203
|
+
// Count entries per list
|
|
204
|
+
const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0 };
|
|
205
|
+
for (const entry of queue) {
|
|
206
|
+
counts[entry.list_index] = (counts[entry.list_index] ?? 0) + 1;
|
|
207
|
+
}
|
|
208
|
+
// Should be roughly equal (10 each)
|
|
209
|
+
expect(counts[0]).toBeGreaterThanOrEqual(9);
|
|
210
|
+
expect(counts[0]).toBeLessThanOrEqual(11);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("initializeSequential", () => {
|
|
215
|
+
test("generates sequential queue with round-robin", () => {
|
|
216
|
+
const lists: ExperimentList[] = [
|
|
217
|
+
{ id: "list_1", name: "list_1", list_number: 0, item_refs: [] },
|
|
218
|
+
{ id: "list_2", name: "list_2", list_number: 1, item_refs: [] },
|
|
219
|
+
{ id: "list_3", name: "list_3", list_number: 2, item_refs: [] },
|
|
220
|
+
];
|
|
221
|
+
const config: DistributionConfig = { strategy_type: "sequential" };
|
|
222
|
+
const maxParticipants = 10;
|
|
223
|
+
|
|
224
|
+
const queue = initializeSequential(config, lists, maxParticipants);
|
|
225
|
+
|
|
226
|
+
expect(queue.length).toBe(10);
|
|
227
|
+
expect(queue[0]?.list_index).toBe(0);
|
|
228
|
+
expect(queue[1]?.list_index).toBe(1);
|
|
229
|
+
expect(queue[2]?.list_index).toBe(2);
|
|
230
|
+
expect(queue[3]?.list_index).toBe(0); // Wraps around (3 % 3 = 0)
|
|
231
|
+
expect(queue[4]?.list_index).toBe(1); // 4 % 3 = 1
|
|
232
|
+
expect(queue[9]?.list_index).toBe(0); // 9 % 3 = 0
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("initializeLatinSquare", () => {
|
|
237
|
+
test("generates queue from Latin square matrix", () => {
|
|
238
|
+
const lists: ExperimentList[] = [
|
|
239
|
+
{ id: "list_1", name: "list_1", list_number: 0, item_refs: [] },
|
|
240
|
+
{ id: "list_2", name: "list_2", list_number: 1, item_refs: [] },
|
|
241
|
+
{ id: "list_3", name: "list_3", list_number: 2, item_refs: [] },
|
|
242
|
+
];
|
|
243
|
+
const config: DistributionConfig = { strategy_type: "latin_square" };
|
|
244
|
+
|
|
245
|
+
const { queue, matrix } = initializeLatinSquare(config, lists);
|
|
246
|
+
|
|
247
|
+
expect(matrix).toHaveLength(3);
|
|
248
|
+
expect(queue.length).toBe(9); // 3x3 matrix
|
|
249
|
+
// Verify queue entries match matrix
|
|
250
|
+
let queueIndex = 0;
|
|
251
|
+
for (let row = 0; row < matrix.length; row++) {
|
|
252
|
+
const matrixRow = matrix[row];
|
|
253
|
+
if (matrixRow) {
|
|
254
|
+
for (let col = 0; col < matrixRow.length; col++) {
|
|
255
|
+
expect(queue[queueIndex]?.list_index).toBe(matrixRow[col]);
|
|
256
|
+
queueIndex++;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("includes both queue and matrix in result", () => {
|
|
263
|
+
const lists: ExperimentList[] = [
|
|
264
|
+
{ id: "list_1", name: "list_1", list_number: 0, item_refs: [] },
|
|
265
|
+
{ id: "list_2", name: "list_2", list_number: 1, item_refs: [] },
|
|
266
|
+
];
|
|
267
|
+
const config: DistributionConfig = { strategy_type: "latin_square" };
|
|
268
|
+
|
|
269
|
+
const result = initializeLatinSquare(config, lists);
|
|
270
|
+
|
|
271
|
+
expect(result).toHaveProperty("queue");
|
|
272
|
+
expect(result).toHaveProperty("matrix");
|
|
273
|
+
expect(Array.isArray(result.queue)).toBe(true);
|
|
274
|
+
expect(Array.isArray(result.matrix)).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("initializeQuotaBased", () => {
|
|
279
|
+
test("generates queue and quotas correctly", () => {
|
|
280
|
+
const lists: ExperimentList[] = [
|
|
281
|
+
{ id: "list_1", name: "list_1", list_number: 0, item_refs: [] },
|
|
282
|
+
{ id: "list_2", name: "list_2", list_number: 1, item_refs: [] },
|
|
283
|
+
{ id: "list_3", name: "list_3", list_number: 2, item_refs: [] },
|
|
284
|
+
];
|
|
285
|
+
const config: DistributionConfig = {
|
|
286
|
+
strategy_type: "quota_based",
|
|
287
|
+
strategy_config: {
|
|
288
|
+
participants_per_list: 5,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const { queue, quotas } = initializeQuotaBased(config, lists);
|
|
293
|
+
|
|
294
|
+
expect(queue.length).toBe(15); // 3 lists * 5 participants
|
|
295
|
+
expect(quotas[0]).toBe(5);
|
|
296
|
+
expect(quotas[1]).toBe(5);
|
|
297
|
+
expect(quotas[2]).toBe(5);
|
|
298
|
+
|
|
299
|
+
// Count entries per list
|
|
300
|
+
const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0 };
|
|
301
|
+
for (const entry of queue) {
|
|
302
|
+
counts[entry.list_index] = (counts[entry.list_index] ?? 0) + 1;
|
|
303
|
+
}
|
|
304
|
+
expect(counts[0]).toBe(5);
|
|
305
|
+
expect(counts[1]).toBe(5);
|
|
306
|
+
expect(counts[2]).toBe(5);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("includes both queue and quotas in result", () => {
|
|
310
|
+
const lists: ExperimentList[] = [
|
|
311
|
+
{ id: "list_1", name: "list_1", list_number: 0, item_refs: [] },
|
|
312
|
+
];
|
|
313
|
+
const config: DistributionConfig = {
|
|
314
|
+
strategy_type: "quota_based",
|
|
315
|
+
strategy_config: {
|
|
316
|
+
participants_per_list: 10,
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const result = initializeQuotaBased(config, lists);
|
|
321
|
+
|
|
322
|
+
expect(result).toHaveProperty("queue");
|
|
323
|
+
expect(result).toHaveProperty("quotas");
|
|
324
|
+
expect(Array.isArray(result.queue)).toBe(true);
|
|
325
|
+
expect(typeof result.quotas).toBe("object");
|
|
326
|
+
});
|
|
327
|
+
});
|