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,402 @@
|
|
|
1
|
+
"""List distribution configuration and strategies for batch experiments.
|
|
2
|
+
|
|
3
|
+
This module provides Pydantic models for configuring list distribution strategies
|
|
4
|
+
in JATOS batch experiments. It supports 8 different distribution strategies for
|
|
5
|
+
assigning participants to experiment lists.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import Field, field_validator, model_validator
|
|
14
|
+
|
|
15
|
+
from bead.data.base import BeadBaseModel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DistributionStrategyType(StrEnum):
|
|
19
|
+
"""Available distribution strategies for list assignment.
|
|
20
|
+
|
|
21
|
+
Attributes
|
|
22
|
+
----------
|
|
23
|
+
RANDOM : str
|
|
24
|
+
Random selection from available lists.
|
|
25
|
+
SEQUENTIAL : str
|
|
26
|
+
Round-robin assignment (list 0, 1, 2, ..., N, 0, 1, ...).
|
|
27
|
+
BALANCED : str
|
|
28
|
+
Assign to least-used list (minimizes imbalance).
|
|
29
|
+
LATIN_SQUARE : str
|
|
30
|
+
Latin square counterbalancing for order effects.
|
|
31
|
+
STRATIFIED : str
|
|
32
|
+
Balance across multiple factors (e.g., condition × list).
|
|
33
|
+
WEIGHTED_RANDOM : str
|
|
34
|
+
Random assignment with non-uniform probabilities.
|
|
35
|
+
QUOTA_BASED : str
|
|
36
|
+
Fixed quota per list, stop when reached.
|
|
37
|
+
METADATA_BASED : str
|
|
38
|
+
Intelligent assignment based on list metadata properties.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
RANDOM = "random"
|
|
42
|
+
SEQUENTIAL = "sequential"
|
|
43
|
+
BALANCED = "balanced"
|
|
44
|
+
LATIN_SQUARE = "latin_square"
|
|
45
|
+
STRATIFIED = "stratified"
|
|
46
|
+
WEIGHTED_RANDOM = "weighted_random"
|
|
47
|
+
QUOTA_BASED = "quota_based"
|
|
48
|
+
METADATA_BASED = "metadata_based"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class QuotaConfig(BeadBaseModel):
|
|
52
|
+
"""Configuration for quota-based assignment.
|
|
53
|
+
|
|
54
|
+
Assigns participants to lists until each list reaches a target quota.
|
|
55
|
+
When all quotas are filled, either raises an error or allows overflow.
|
|
56
|
+
|
|
57
|
+
Attributes
|
|
58
|
+
----------
|
|
59
|
+
participants_per_list : int
|
|
60
|
+
Target number of participants per list (must be > 0).
|
|
61
|
+
allow_overflow : bool
|
|
62
|
+
Whether to allow assignment after quota reached (default: False).
|
|
63
|
+
If True, uses balanced assignment after quotas filled.
|
|
64
|
+
If False, raises error when all quotas reached.
|
|
65
|
+
|
|
66
|
+
Examples
|
|
67
|
+
--------
|
|
68
|
+
>>> config = QuotaConfig(participants_per_list=25, allow_overflow=False)
|
|
69
|
+
>>> config.participants_per_list
|
|
70
|
+
25
|
|
71
|
+
|
|
72
|
+
Raises
|
|
73
|
+
------
|
|
74
|
+
ValueError
|
|
75
|
+
If participants_per_list <= 0.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
participants_per_list: int = Field(..., gt=0)
|
|
79
|
+
allow_overflow: bool = False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class WeightedRandomConfig(BeadBaseModel):
|
|
83
|
+
"""Configuration for weighted random assignment.
|
|
84
|
+
|
|
85
|
+
Assigns lists with non-uniform probabilities based on metadata expressions.
|
|
86
|
+
Useful for oversampling certain lists or adaptive designs.
|
|
87
|
+
|
|
88
|
+
Attributes
|
|
89
|
+
----------
|
|
90
|
+
weight_expression : str
|
|
91
|
+
JavaScript expression to compute weight from list metadata.
|
|
92
|
+
Expression is evaluated with 'list_metadata' in scope.
|
|
93
|
+
Example: "list_metadata.priority || 1.0"
|
|
94
|
+
normalize_weights : bool
|
|
95
|
+
Whether to normalize weights to sum to 1.0 (default: True).
|
|
96
|
+
|
|
97
|
+
Examples
|
|
98
|
+
--------
|
|
99
|
+
>>> config = WeightedRandomConfig(
|
|
100
|
+
... weight_expression="list_metadata.priority || 1.0",
|
|
101
|
+
... normalize_weights=True
|
|
102
|
+
... )
|
|
103
|
+
>>> config.weight_expression
|
|
104
|
+
'list_metadata.priority || 1.0'
|
|
105
|
+
|
|
106
|
+
Raises
|
|
107
|
+
------
|
|
108
|
+
ValueError
|
|
109
|
+
If weight_expression is empty.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
weight_expression: str = Field(..., min_length=1)
|
|
113
|
+
normalize_weights: bool = True
|
|
114
|
+
|
|
115
|
+
@field_validator("weight_expression")
|
|
116
|
+
@classmethod
|
|
117
|
+
def validate_weight_expression(cls, v: str) -> str:
|
|
118
|
+
"""Validate weight expression is non-empty."""
|
|
119
|
+
if not v or not v.strip():
|
|
120
|
+
raise ValueError(
|
|
121
|
+
"weight_expression must be non-empty. "
|
|
122
|
+
"Provide a JavaScript expression like 'list_metadata.priority || 1.0'. "
|
|
123
|
+
"This expression will be evaluated for each list to compute weights."
|
|
124
|
+
)
|
|
125
|
+
return v.strip()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class LatinSquareConfig(BeadBaseModel):
|
|
129
|
+
"""Configuration for Latin square counterbalancing.
|
|
130
|
+
|
|
131
|
+
Generates a Latin square design for systematic counterbalancing of order effects.
|
|
132
|
+
Ensures each condition appears at each position across participants.
|
|
133
|
+
|
|
134
|
+
Attributes
|
|
135
|
+
----------
|
|
136
|
+
balanced : bool
|
|
137
|
+
Use balanced Latin square vs. standard (default: True).
|
|
138
|
+
Balanced squares use Bradley's (1958) algorithm.
|
|
139
|
+
|
|
140
|
+
Examples
|
|
141
|
+
--------
|
|
142
|
+
>>> config = LatinSquareConfig(balanced=True)
|
|
143
|
+
>>> config.balanced
|
|
144
|
+
True
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
balanced: bool = True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class MetadataBasedConfig(BeadBaseModel):
|
|
151
|
+
"""Configuration for metadata-based assignment.
|
|
152
|
+
|
|
153
|
+
Filters and ranks lists based on metadata expressions before assignment.
|
|
154
|
+
Useful for assignment based on list properties like difficulty or priority.
|
|
155
|
+
|
|
156
|
+
Attributes
|
|
157
|
+
----------
|
|
158
|
+
filter_expression : str | None
|
|
159
|
+
JavaScript boolean expression to filter lists (default: None).
|
|
160
|
+
Expression is evaluated with 'list_metadata' in scope.
|
|
161
|
+
Only lists where expression evaluates to true are eligible.
|
|
162
|
+
Example: "list_metadata.difficulty === 'easy'"
|
|
163
|
+
rank_expression : str | None
|
|
164
|
+
JavaScript expression to rank/sort lists (default: None).
|
|
165
|
+
Expression is evaluated with 'list_metadata' in scope.
|
|
166
|
+
Lists are sorted by this value before assignment.
|
|
167
|
+
Example: "list_metadata.priority || 0"
|
|
168
|
+
rank_ascending : bool
|
|
169
|
+
Sort ascending vs descending when using rank_expression (default: True).
|
|
170
|
+
|
|
171
|
+
Examples
|
|
172
|
+
--------
|
|
173
|
+
>>> config = MetadataBasedConfig(
|
|
174
|
+
... filter_expression="list_metadata.difficulty === 'easy'",
|
|
175
|
+
... rank_expression="list_metadata.priority || 0",
|
|
176
|
+
... rank_ascending=False
|
|
177
|
+
... )
|
|
178
|
+
>>> config.filter_expression
|
|
179
|
+
"list_metadata.difficulty === 'easy'"
|
|
180
|
+
|
|
181
|
+
Raises
|
|
182
|
+
------
|
|
183
|
+
ValueError
|
|
184
|
+
If both filter_expression and rank_expression are None.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
filter_expression: str | None = None
|
|
188
|
+
rank_expression: str | None = None
|
|
189
|
+
rank_ascending: bool = True
|
|
190
|
+
|
|
191
|
+
@model_validator(mode="after")
|
|
192
|
+
def validate_at_least_one_expression(self) -> MetadataBasedConfig:
|
|
193
|
+
"""Validate at least one expression is provided."""
|
|
194
|
+
if self.filter_expression is None and self.rank_expression is None:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
"MetadataBasedConfig requires at least one of 'filter_expression' "
|
|
197
|
+
"or 'rank_expression'. Got neither. "
|
|
198
|
+
"Provide 'filter_expression' to filter lists (e.g., "
|
|
199
|
+
"\"list_metadata.difficulty === 'easy'\") or 'rank_expression' to "
|
|
200
|
+
'rank lists (e.g., "list_metadata.priority || 0").'
|
|
201
|
+
)
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class StratifiedConfig(BeadBaseModel):
|
|
206
|
+
"""Configuration for stratified assignment.
|
|
207
|
+
|
|
208
|
+
Balances assignment across multiple factors (e.g., list × condition).
|
|
209
|
+
Ensures even distribution across factor combinations.
|
|
210
|
+
|
|
211
|
+
Attributes
|
|
212
|
+
----------
|
|
213
|
+
factors : list[str]
|
|
214
|
+
List metadata keys to use as stratification factors (must be non-empty).
|
|
215
|
+
Lists are grouped by unique combinations of these factor values.
|
|
216
|
+
Example: ["condition", "verb_type"] groups by condition × verb_type.
|
|
217
|
+
|
|
218
|
+
Examples
|
|
219
|
+
--------
|
|
220
|
+
>>> config = StratifiedConfig(factors=["condition", "verb_type"])
|
|
221
|
+
>>> config.factors
|
|
222
|
+
['condition', 'verb_type']
|
|
223
|
+
|
|
224
|
+
Raises
|
|
225
|
+
------
|
|
226
|
+
ValueError
|
|
227
|
+
If factors list is empty.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
factors: list[str] = Field(..., min_length=1)
|
|
231
|
+
|
|
232
|
+
@field_validator("factors")
|
|
233
|
+
@classmethod
|
|
234
|
+
def validate_factors(cls, v: list[str]) -> list[str]:
|
|
235
|
+
"""Validate factors list is non-empty and contains no duplicates."""
|
|
236
|
+
if not v:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
"StratifiedConfig requires at least one factor in 'factors' list. "
|
|
239
|
+
"Got empty list. "
|
|
240
|
+
"Provide a list of metadata keys to stratify by, e.g., "
|
|
241
|
+
"['condition', 'verb_type']."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if len(v) != len(set(v)):
|
|
245
|
+
duplicates = [x for x in v if v.count(x) > 1]
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"StratifiedConfig 'factors' contains duplicates: {duplicates}. "
|
|
248
|
+
f"Each factor must appear only once. "
|
|
249
|
+
f"Remove duplicate entries from your factors list."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
return v
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class ListDistributionStrategy(BeadBaseModel):
|
|
256
|
+
"""Configuration for list distribution strategy in batch experiments.
|
|
257
|
+
|
|
258
|
+
Defines how participants are assigned to experiment lists using JATOS batch
|
|
259
|
+
sessions for server-side state management.
|
|
260
|
+
|
|
261
|
+
Attributes
|
|
262
|
+
----------
|
|
263
|
+
strategy_type : DistributionStrategyType
|
|
264
|
+
Type of distribution strategy (required, no default).
|
|
265
|
+
strategy_config : dict[str, Any]
|
|
266
|
+
Strategy-specific configuration parameters (default: empty dict).
|
|
267
|
+
Required keys depend on strategy_type:
|
|
268
|
+
- quota_based: requires 'participants_per_list'
|
|
269
|
+
- weighted_random: requires 'weight_expression'
|
|
270
|
+
- metadata_based: requires 'filter_expression' or 'rank_expression'
|
|
271
|
+
- stratified: requires 'factors'
|
|
272
|
+
max_participants : int | None
|
|
273
|
+
Maximum total participants across all lists (None = unlimited) (default: None).
|
|
274
|
+
error_on_exhaustion : bool
|
|
275
|
+
Raise error when max_participants reached (default: True).
|
|
276
|
+
If False, continues assignment (may exceed max_participants).
|
|
277
|
+
debug_mode : bool
|
|
278
|
+
Enable debug mode (always assign same list) (default: False).
|
|
279
|
+
Useful for development testing without batch session state.
|
|
280
|
+
debug_list_index : int
|
|
281
|
+
List index to use in debug mode (default: 0, must be >= 0).
|
|
282
|
+
|
|
283
|
+
Examples
|
|
284
|
+
--------
|
|
285
|
+
>>> # Balanced assignment
|
|
286
|
+
>>> strategy = ListDistributionStrategy(
|
|
287
|
+
... strategy_type=DistributionStrategyType.BALANCED,
|
|
288
|
+
... max_participants=100
|
|
289
|
+
... )
|
|
290
|
+
|
|
291
|
+
>>> # Quota-based with 25 per list
|
|
292
|
+
>>> strategy = ListDistributionStrategy(
|
|
293
|
+
... strategy_type=DistributionStrategyType.QUOTA_BASED,
|
|
294
|
+
... strategy_config={"participants_per_list": 25, "allow_overflow": False},
|
|
295
|
+
... max_participants=400
|
|
296
|
+
... )
|
|
297
|
+
|
|
298
|
+
>>> # Debug mode (always list 0)
|
|
299
|
+
>>> strategy = ListDistributionStrategy(
|
|
300
|
+
... strategy_type=DistributionStrategyType.RANDOM,
|
|
301
|
+
... debug_mode=True,
|
|
302
|
+
... debug_list_index=0
|
|
303
|
+
... )
|
|
304
|
+
|
|
305
|
+
Raises
|
|
306
|
+
------
|
|
307
|
+
ValueError
|
|
308
|
+
If strategy_config doesn't match requirements for strategy_type.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
strategy_type: DistributionStrategyType
|
|
312
|
+
strategy_config: dict[str, Any] = Field(default_factory=dict)
|
|
313
|
+
max_participants: int | None = Field(default=None, ge=1)
|
|
314
|
+
error_on_exhaustion: bool = True
|
|
315
|
+
debug_mode: bool = False
|
|
316
|
+
debug_list_index: int = Field(default=0, ge=0)
|
|
317
|
+
|
|
318
|
+
@model_validator(mode="after")
|
|
319
|
+
def validate_strategy_config_matches_type(self) -> ListDistributionStrategy:
|
|
320
|
+
"""Validate strategy_config has required keys for strategy_type.
|
|
321
|
+
|
|
322
|
+
Raises
|
|
323
|
+
------
|
|
324
|
+
ValueError
|
|
325
|
+
If required configuration keys are missing for the strategy type.
|
|
326
|
+
"""
|
|
327
|
+
strategy = self.strategy_type
|
|
328
|
+
config = self.strategy_config
|
|
329
|
+
|
|
330
|
+
# Quota-based requires participants_per_list
|
|
331
|
+
if strategy == DistributionStrategyType.QUOTA_BASED:
|
|
332
|
+
if "participants_per_list" not in config:
|
|
333
|
+
raise ValueError(
|
|
334
|
+
f"QuotaConfig requires 'participants_per_list'. "
|
|
335
|
+
f"Got keys: {list(config.keys())}. "
|
|
336
|
+
f"Add 'participants_per_list: <int>' to strategy_config."
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Validate it's a positive integer
|
|
340
|
+
ppl = config["participants_per_list"]
|
|
341
|
+
if not isinstance(ppl, int) or ppl <= 0:
|
|
342
|
+
raise ValueError(
|
|
343
|
+
f"'participants_per_list' must be positive int. "
|
|
344
|
+
f"Got: {ppl} ({type(ppl).__name__})."
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Weighted random requires weight_expression
|
|
348
|
+
elif strategy == DistributionStrategyType.WEIGHTED_RANDOM:
|
|
349
|
+
if "weight_expression" not in config:
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"WeightedRandomConfig requires 'weight_expression'. "
|
|
352
|
+
f"Got keys: {list(config.keys())}. "
|
|
353
|
+
f"Add 'weight_expression: <string>' to strategy_config."
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
expr = config["weight_expression"]
|
|
357
|
+
if not isinstance(expr, str) or not expr.strip():
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"'weight_expression' must be a non-empty string. "
|
|
360
|
+
f"Got: {expr!r} ({type(expr).__name__})."
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Metadata-based requires filter_expression or rank_expression
|
|
364
|
+
elif strategy == DistributionStrategyType.METADATA_BASED:
|
|
365
|
+
has_filter = "filter_expression" in config and config["filter_expression"]
|
|
366
|
+
has_rank = "rank_expression" in config and config["rank_expression"]
|
|
367
|
+
|
|
368
|
+
if not has_filter and not has_rank:
|
|
369
|
+
raise ValueError(
|
|
370
|
+
f"MetadataBasedConfig requires 'filter_expression' "
|
|
371
|
+
f"or 'rank_expression'. Got keys: {list(config.keys())}."
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Stratified requires factors list
|
|
375
|
+
elif strategy == DistributionStrategyType.STRATIFIED:
|
|
376
|
+
if "factors" not in config:
|
|
377
|
+
raise ValueError(
|
|
378
|
+
f"StratifiedConfig requires 'factors'. "
|
|
379
|
+
f"Got keys: {list(config.keys())}. "
|
|
380
|
+
f"Add 'factors: [<list_of_keys>]' to strategy_config."
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
factors = config["factors"]
|
|
384
|
+
if not isinstance(factors, list) or not factors:
|
|
385
|
+
raise ValueError(
|
|
386
|
+
f"StratifiedConfig 'factors' must be a non-empty list of strings. "
|
|
387
|
+
f"Got: {factors!r} (type: {type(factors).__name__}). "
|
|
388
|
+
f"Provide a list like ['condition', 'verb_type']."
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
return self
|
|
392
|
+
|
|
393
|
+
@field_validator("debug_list_index")
|
|
394
|
+
@classmethod
|
|
395
|
+
def validate_debug_list_index(cls, v: int) -> int:
|
|
396
|
+
"""Validate debug_list_index is non-negative."""
|
|
397
|
+
if v < 0:
|
|
398
|
+
raise ValueError(
|
|
399
|
+
f"debug_list_index must be >= 0. Got: {v}. "
|
|
400
|
+
f"Set to 0 for first list, 1 for second list, etc."
|
|
401
|
+
)
|
|
402
|
+
return v
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""JATOS deployment module for exporting studies to JATOS format."""
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""JATOS REST API client.
|
|
2
|
+
|
|
3
|
+
This module provides the JATOSClient class for interacting with JATOS servers
|
|
4
|
+
via the REST API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from bead.data.base import JsonValue
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JATOSClient:
|
|
17
|
+
"""Client for JATOS REST API.
|
|
18
|
+
|
|
19
|
+
Supports uploading study packages (.jzip), listing studies, deleting
|
|
20
|
+
studies, and retrieving study results.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
base_url
|
|
25
|
+
Base URL for JATOS instance (e.g., "https://jatos.example.com").
|
|
26
|
+
api_token
|
|
27
|
+
API token for authentication.
|
|
28
|
+
|
|
29
|
+
Attributes
|
|
30
|
+
----------
|
|
31
|
+
base_url : str
|
|
32
|
+
Base URL for JATOS instance (trailing slash removed).
|
|
33
|
+
api_token : str
|
|
34
|
+
API token for authentication.
|
|
35
|
+
session : requests.Session
|
|
36
|
+
HTTP session with authentication headers configured.
|
|
37
|
+
|
|
38
|
+
Examples
|
|
39
|
+
--------
|
|
40
|
+
>>> client = JATOSClient("https://jatos.example.com", "my-api-token")
|
|
41
|
+
>>> # studies = client.list_studies()
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, base_url: str, api_token: str) -> None:
|
|
45
|
+
self.base_url = base_url.rstrip("/")
|
|
46
|
+
self.api_token = api_token
|
|
47
|
+
self.session = requests.Session()
|
|
48
|
+
self.session.headers.update({"Authorization": f"Bearer {api_token}"})
|
|
49
|
+
|
|
50
|
+
def upload_study(self, jzip_path: Path) -> dict[str, JsonValue]:
|
|
51
|
+
"""Upload study package to JATOS.
|
|
52
|
+
|
|
53
|
+
POST /api/v1/studies
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
jzip_path : Path
|
|
58
|
+
Path to .jzip file to upload.
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
dict[str, JsonValue]
|
|
63
|
+
Response with study ID, UUID, and URL.
|
|
64
|
+
|
|
65
|
+
Raises
|
|
66
|
+
------
|
|
67
|
+
requests.HTTPError
|
|
68
|
+
If the upload fails.
|
|
69
|
+
FileNotFoundError
|
|
70
|
+
If the .jzip file does not exist.
|
|
71
|
+
|
|
72
|
+
Examples
|
|
73
|
+
--------
|
|
74
|
+
>>> client = JATOSClient("https://jatos.example.com", "token")
|
|
75
|
+
>>> # result = client.upload_study(Path("study.jzip"))
|
|
76
|
+
>>> # print(result["id"])
|
|
77
|
+
"""
|
|
78
|
+
if not jzip_path.exists():
|
|
79
|
+
raise FileNotFoundError(f".jzip file not found: {jzip_path}")
|
|
80
|
+
|
|
81
|
+
url = f"{self.base_url}/api/v1/studies"
|
|
82
|
+
|
|
83
|
+
with open(jzip_path, "rb") as f:
|
|
84
|
+
files = {"file": (jzip_path.name, f, "application/zip")}
|
|
85
|
+
response = self.session.post(url, files=files)
|
|
86
|
+
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
return response.json()
|
|
89
|
+
|
|
90
|
+
def list_studies(self) -> list[dict[str, JsonValue]]:
|
|
91
|
+
"""List all studies.
|
|
92
|
+
|
|
93
|
+
GET /api/v1/studies
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
list[dict[str, JsonValue]]
|
|
98
|
+
List of study dictionaries.
|
|
99
|
+
|
|
100
|
+
Raises
|
|
101
|
+
------
|
|
102
|
+
requests.HTTPError
|
|
103
|
+
If the request fails.
|
|
104
|
+
|
|
105
|
+
Examples
|
|
106
|
+
--------
|
|
107
|
+
>>> client = JATOSClient("https://jatos.example.com", "token")
|
|
108
|
+
>>> # studies = client.list_studies()
|
|
109
|
+
>>> # print(len(studies))
|
|
110
|
+
"""
|
|
111
|
+
url = f"{self.base_url}/api/v1/studies"
|
|
112
|
+
response = self.session.get(url)
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
return response.json()
|
|
115
|
+
|
|
116
|
+
def get_study(self, study_id: int) -> dict[str, JsonValue]:
|
|
117
|
+
"""Get study details.
|
|
118
|
+
|
|
119
|
+
GET /api/v1/studies/{study_id}
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
study_id : int
|
|
124
|
+
Study ID.
|
|
125
|
+
|
|
126
|
+
Returns
|
|
127
|
+
-------
|
|
128
|
+
dict[str, JsonValue]
|
|
129
|
+
Study details dictionary.
|
|
130
|
+
|
|
131
|
+
Raises
|
|
132
|
+
------
|
|
133
|
+
requests.HTTPError
|
|
134
|
+
If the request fails.
|
|
135
|
+
|
|
136
|
+
Examples
|
|
137
|
+
--------
|
|
138
|
+
>>> client = JATOSClient("https://jatos.example.com", "token")
|
|
139
|
+
>>> # study = client.get_study(123)
|
|
140
|
+
>>> # print(study["title"])
|
|
141
|
+
"""
|
|
142
|
+
url = f"{self.base_url}/api/v1/studies/{study_id}"
|
|
143
|
+
response = self.session.get(url)
|
|
144
|
+
response.raise_for_status()
|
|
145
|
+
return response.json()
|
|
146
|
+
|
|
147
|
+
def delete_study(self, study_id: int) -> None:
|
|
148
|
+
"""Delete study.
|
|
149
|
+
|
|
150
|
+
DELETE /api/v1/studies/{study_id}
|
|
151
|
+
|
|
152
|
+
Parameters
|
|
153
|
+
----------
|
|
154
|
+
study_id : int
|
|
155
|
+
Study ID to delete.
|
|
156
|
+
|
|
157
|
+
Raises
|
|
158
|
+
------
|
|
159
|
+
requests.HTTPError
|
|
160
|
+
If the request fails.
|
|
161
|
+
|
|
162
|
+
Examples
|
|
163
|
+
--------
|
|
164
|
+
>>> client = JATOSClient("https://jatos.example.com", "token")
|
|
165
|
+
>>> # client.delete_study(123)
|
|
166
|
+
"""
|
|
167
|
+
url = f"{self.base_url}/api/v1/studies/{study_id}"
|
|
168
|
+
response = self.session.delete(url)
|
|
169
|
+
response.raise_for_status()
|
|
170
|
+
|
|
171
|
+
def get_results(self, study_id: int) -> list[int]:
|
|
172
|
+
"""Get all result IDs for a study.
|
|
173
|
+
|
|
174
|
+
GET /api/v1/studies/{study_id}/results
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
study_id : int
|
|
179
|
+
Study ID.
|
|
180
|
+
|
|
181
|
+
Returns
|
|
182
|
+
-------
|
|
183
|
+
list[int]
|
|
184
|
+
List of result IDs.
|
|
185
|
+
|
|
186
|
+
Raises
|
|
187
|
+
------
|
|
188
|
+
requests.HTTPError
|
|
189
|
+
If the request fails.
|
|
190
|
+
|
|
191
|
+
Examples
|
|
192
|
+
--------
|
|
193
|
+
>>> client = JATOSClient("https://jatos.example.com", "token")
|
|
194
|
+
>>> # result_ids = client.get_results(123)
|
|
195
|
+
>>> # print(len(result_ids))
|
|
196
|
+
"""
|
|
197
|
+
url = f"{self.base_url}/api/v1/studies/{study_id}/results"
|
|
198
|
+
response = self.session.get(url)
|
|
199
|
+
response.raise_for_status()
|
|
200
|
+
return response.json()
|