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.
Files changed (231) hide show
  1. bead/__init__.py +11 -0
  2. bead/__main__.py +11 -0
  3. bead/active_learning/__init__.py +15 -0
  4. bead/active_learning/config.py +231 -0
  5. bead/active_learning/loop.py +566 -0
  6. bead/active_learning/models/__init__.py +24 -0
  7. bead/active_learning/models/base.py +852 -0
  8. bead/active_learning/models/binary.py +910 -0
  9. bead/active_learning/models/categorical.py +943 -0
  10. bead/active_learning/models/cloze.py +862 -0
  11. bead/active_learning/models/forced_choice.py +956 -0
  12. bead/active_learning/models/free_text.py +773 -0
  13. bead/active_learning/models/lora.py +365 -0
  14. bead/active_learning/models/magnitude.py +835 -0
  15. bead/active_learning/models/multi_select.py +795 -0
  16. bead/active_learning/models/ordinal_scale.py +811 -0
  17. bead/active_learning/models/peft_adapter.py +155 -0
  18. bead/active_learning/models/random_effects.py +639 -0
  19. bead/active_learning/selection.py +354 -0
  20. bead/active_learning/strategies.py +391 -0
  21. bead/active_learning/trainers/__init__.py +26 -0
  22. bead/active_learning/trainers/base.py +210 -0
  23. bead/active_learning/trainers/data_collator.py +172 -0
  24. bead/active_learning/trainers/dataset_utils.py +261 -0
  25. bead/active_learning/trainers/huggingface.py +304 -0
  26. bead/active_learning/trainers/lightning.py +324 -0
  27. bead/active_learning/trainers/metrics.py +424 -0
  28. bead/active_learning/trainers/mixed_effects.py +551 -0
  29. bead/active_learning/trainers/model_wrapper.py +509 -0
  30. bead/active_learning/trainers/registry.py +104 -0
  31. bead/adapters/__init__.py +11 -0
  32. bead/adapters/huggingface.py +61 -0
  33. bead/behavioral/__init__.py +116 -0
  34. bead/behavioral/analytics.py +646 -0
  35. bead/behavioral/extraction.py +343 -0
  36. bead/behavioral/merging.py +343 -0
  37. bead/cli/__init__.py +11 -0
  38. bead/cli/active_learning.py +513 -0
  39. bead/cli/active_learning_commands.py +779 -0
  40. bead/cli/completion.py +359 -0
  41. bead/cli/config.py +624 -0
  42. bead/cli/constraint_builders.py +286 -0
  43. bead/cli/deployment.py +859 -0
  44. bead/cli/deployment_trials.py +493 -0
  45. bead/cli/deployment_ui.py +332 -0
  46. bead/cli/display.py +378 -0
  47. bead/cli/items.py +960 -0
  48. bead/cli/items_factories.py +776 -0
  49. bead/cli/list_constraints.py +714 -0
  50. bead/cli/lists.py +490 -0
  51. bead/cli/main.py +430 -0
  52. bead/cli/models.py +877 -0
  53. bead/cli/resource_loaders.py +621 -0
  54. bead/cli/resources.py +1036 -0
  55. bead/cli/shell.py +356 -0
  56. bead/cli/simulate.py +840 -0
  57. bead/cli/templates.py +1158 -0
  58. bead/cli/training.py +1080 -0
  59. bead/cli/utils.py +614 -0
  60. bead/cli/workflow.py +1273 -0
  61. bead/config/__init__.py +68 -0
  62. bead/config/active_learning.py +1009 -0
  63. bead/config/config.py +192 -0
  64. bead/config/defaults.py +118 -0
  65. bead/config/deployment.py +217 -0
  66. bead/config/env.py +147 -0
  67. bead/config/item.py +45 -0
  68. bead/config/list.py +193 -0
  69. bead/config/loader.py +149 -0
  70. bead/config/logging.py +42 -0
  71. bead/config/model.py +49 -0
  72. bead/config/paths.py +46 -0
  73. bead/config/profiles.py +320 -0
  74. bead/config/resources.py +47 -0
  75. bead/config/serialization.py +210 -0
  76. bead/config/simulation.py +206 -0
  77. bead/config/template.py +238 -0
  78. bead/config/validation.py +267 -0
  79. bead/data/__init__.py +65 -0
  80. bead/data/base.py +87 -0
  81. bead/data/identifiers.py +97 -0
  82. bead/data/language_codes.py +61 -0
  83. bead/data/metadata.py +270 -0
  84. bead/data/range.py +123 -0
  85. bead/data/repository.py +358 -0
  86. bead/data/serialization.py +249 -0
  87. bead/data/timestamps.py +89 -0
  88. bead/data/validation.py +349 -0
  89. bead/data_collection/__init__.py +11 -0
  90. bead/data_collection/jatos.py +223 -0
  91. bead/data_collection/merger.py +154 -0
  92. bead/data_collection/prolific.py +198 -0
  93. bead/deployment/__init__.py +5 -0
  94. bead/deployment/distribution.py +402 -0
  95. bead/deployment/jatos/__init__.py +1 -0
  96. bead/deployment/jatos/api.py +200 -0
  97. bead/deployment/jatos/exporter.py +210 -0
  98. bead/deployment/jspsych/__init__.py +9 -0
  99. bead/deployment/jspsych/biome.json +44 -0
  100. bead/deployment/jspsych/config.py +411 -0
  101. bead/deployment/jspsych/generator.py +598 -0
  102. bead/deployment/jspsych/package.json +51 -0
  103. bead/deployment/jspsych/pnpm-lock.yaml +2141 -0
  104. bead/deployment/jspsych/randomizer.py +299 -0
  105. bead/deployment/jspsych/src/lib/list-distributor.test.ts +327 -0
  106. bead/deployment/jspsych/src/lib/list-distributor.ts +1282 -0
  107. bead/deployment/jspsych/src/lib/randomizer.test.ts +232 -0
  108. bead/deployment/jspsych/src/lib/randomizer.ts +367 -0
  109. bead/deployment/jspsych/src/plugins/cloze-dropdown.ts +252 -0
  110. bead/deployment/jspsych/src/plugins/forced-choice.ts +265 -0
  111. bead/deployment/jspsych/src/plugins/plugins.test.ts +141 -0
  112. bead/deployment/jspsych/src/plugins/rating.ts +248 -0
  113. bead/deployment/jspsych/src/slopit/index.ts +9 -0
  114. bead/deployment/jspsych/src/types/jatos.d.ts +256 -0
  115. bead/deployment/jspsych/src/types/jspsych.d.ts +228 -0
  116. bead/deployment/jspsych/templates/experiment.css +1 -0
  117. bead/deployment/jspsych/templates/experiment.js.template +289 -0
  118. bead/deployment/jspsych/templates/index.html +51 -0
  119. bead/deployment/jspsych/templates/randomizer.js +241 -0
  120. bead/deployment/jspsych/templates/randomizer.js.template +313 -0
  121. bead/deployment/jspsych/trials.py +723 -0
  122. bead/deployment/jspsych/tsconfig.json +23 -0
  123. bead/deployment/jspsych/tsup.config.ts +30 -0
  124. bead/deployment/jspsych/ui/__init__.py +1 -0
  125. bead/deployment/jspsych/ui/components.py +383 -0
  126. bead/deployment/jspsych/ui/styles.py +411 -0
  127. bead/dsl/__init__.py +80 -0
  128. bead/dsl/ast.py +168 -0
  129. bead/dsl/context.py +178 -0
  130. bead/dsl/errors.py +71 -0
  131. bead/dsl/evaluator.py +570 -0
  132. bead/dsl/grammar.lark +81 -0
  133. bead/dsl/parser.py +231 -0
  134. bead/dsl/stdlib.py +929 -0
  135. bead/evaluation/__init__.py +13 -0
  136. bead/evaluation/convergence.py +485 -0
  137. bead/evaluation/interannotator.py +398 -0
  138. bead/items/__init__.py +40 -0
  139. bead/items/adapters/__init__.py +70 -0
  140. bead/items/adapters/anthropic.py +224 -0
  141. bead/items/adapters/api_utils.py +167 -0
  142. bead/items/adapters/base.py +216 -0
  143. bead/items/adapters/google.py +259 -0
  144. bead/items/adapters/huggingface.py +1074 -0
  145. bead/items/adapters/openai.py +323 -0
  146. bead/items/adapters/registry.py +202 -0
  147. bead/items/adapters/sentence_transformers.py +224 -0
  148. bead/items/adapters/togetherai.py +309 -0
  149. bead/items/binary.py +515 -0
  150. bead/items/cache.py +558 -0
  151. bead/items/categorical.py +593 -0
  152. bead/items/cloze.py +757 -0
  153. bead/items/constructor.py +784 -0
  154. bead/items/forced_choice.py +413 -0
  155. bead/items/free_text.py +681 -0
  156. bead/items/generation.py +432 -0
  157. bead/items/item.py +396 -0
  158. bead/items/item_template.py +787 -0
  159. bead/items/magnitude.py +573 -0
  160. bead/items/multi_select.py +621 -0
  161. bead/items/ordinal_scale.py +569 -0
  162. bead/items/scoring.py +448 -0
  163. bead/items/validation.py +723 -0
  164. bead/lists/__init__.py +30 -0
  165. bead/lists/balancer.py +263 -0
  166. bead/lists/constraints.py +1067 -0
  167. bead/lists/experiment_list.py +286 -0
  168. bead/lists/list_collection.py +378 -0
  169. bead/lists/partitioner.py +1141 -0
  170. bead/lists/stratification.py +254 -0
  171. bead/participants/__init__.py +73 -0
  172. bead/participants/collection.py +699 -0
  173. bead/participants/merging.py +312 -0
  174. bead/participants/metadata_spec.py +491 -0
  175. bead/participants/models.py +276 -0
  176. bead/resources/__init__.py +29 -0
  177. bead/resources/adapters/__init__.py +19 -0
  178. bead/resources/adapters/base.py +104 -0
  179. bead/resources/adapters/cache.py +128 -0
  180. bead/resources/adapters/glazing.py +508 -0
  181. bead/resources/adapters/registry.py +117 -0
  182. bead/resources/adapters/unimorph.py +796 -0
  183. bead/resources/classification.py +856 -0
  184. bead/resources/constraint_builders.py +329 -0
  185. bead/resources/constraints.py +165 -0
  186. bead/resources/lexical_item.py +223 -0
  187. bead/resources/lexicon.py +744 -0
  188. bead/resources/loaders.py +209 -0
  189. bead/resources/template.py +441 -0
  190. bead/resources/template_collection.py +707 -0
  191. bead/resources/template_generation.py +349 -0
  192. bead/simulation/__init__.py +29 -0
  193. bead/simulation/annotators/__init__.py +15 -0
  194. bead/simulation/annotators/base.py +175 -0
  195. bead/simulation/annotators/distance_based.py +135 -0
  196. bead/simulation/annotators/lm_based.py +114 -0
  197. bead/simulation/annotators/oracle.py +182 -0
  198. bead/simulation/annotators/random.py +181 -0
  199. bead/simulation/dsl_extension/__init__.py +3 -0
  200. bead/simulation/noise_models/__init__.py +13 -0
  201. bead/simulation/noise_models/base.py +42 -0
  202. bead/simulation/noise_models/random_noise.py +82 -0
  203. bead/simulation/noise_models/systematic.py +132 -0
  204. bead/simulation/noise_models/temperature.py +86 -0
  205. bead/simulation/runner.py +144 -0
  206. bead/simulation/strategies/__init__.py +23 -0
  207. bead/simulation/strategies/base.py +123 -0
  208. bead/simulation/strategies/binary.py +103 -0
  209. bead/simulation/strategies/categorical.py +123 -0
  210. bead/simulation/strategies/cloze.py +224 -0
  211. bead/simulation/strategies/forced_choice.py +127 -0
  212. bead/simulation/strategies/free_text.py +105 -0
  213. bead/simulation/strategies/magnitude.py +116 -0
  214. bead/simulation/strategies/multi_select.py +129 -0
  215. bead/simulation/strategies/ordinal_scale.py +131 -0
  216. bead/templates/__init__.py +27 -0
  217. bead/templates/adapters/__init__.py +17 -0
  218. bead/templates/adapters/base.py +128 -0
  219. bead/templates/adapters/cache.py +178 -0
  220. bead/templates/adapters/huggingface.py +312 -0
  221. bead/templates/combinatorics.py +103 -0
  222. bead/templates/filler.py +605 -0
  223. bead/templates/renderers.py +177 -0
  224. bead/templates/resolver.py +178 -0
  225. bead/templates/strategies.py +1806 -0
  226. bead/templates/streaming.py +195 -0
  227. bead-0.1.0.dist-info/METADATA +212 -0
  228. bead-0.1.0.dist-info/RECORD +231 -0
  229. bead-0.1.0.dist-info/WHEEL +4 -0
  230. bead-0.1.0.dist-info/entry_points.txt +2 -0
  231. 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()