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
bead/cli/deployment.py ADDED
@@ -0,0 +1,859 @@
1
+ """Deployment commands for bead CLI.
2
+
3
+ This module provides commands for generating and deploying jsPsych experiments
4
+ (Stage 5 of the bead pipeline).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import cast
12
+ from uuid import UUID
13
+
14
+ import click
15
+ from pydantic import ValidationError
16
+ from rich.console import Console
17
+ from rich.progress import Progress, SpinnerColumn, TextColumn
18
+
19
+ from bead.cli.utils import print_error, print_info, print_success
20
+ from bead.data.base import JsonValue
21
+ from bead.deployment.distribution import (
22
+ DistributionStrategyType,
23
+ ListDistributionStrategy,
24
+ )
25
+ from bead.deployment.jatos.api import JATOSClient
26
+ from bead.deployment.jatos.exporter import JATOSExporter
27
+ from bead.deployment.jspsych.config import ExperimentConfig
28
+ from bead.deployment.jspsych.generator import JsPsychExperimentGenerator
29
+ from bead.items.item import Item
30
+ from bead.items.item_template import ItemTemplate, PresentationSpec, TaskSpec
31
+ from bead.lists import ExperimentList
32
+
33
+ console = Console()
34
+
35
+
36
+ @click.group()
37
+ def deployment() -> None:
38
+ r"""Deployment commands (Stage 5).
39
+
40
+ Commands for generating and deploying jsPsych experiments.
41
+
42
+ \b
43
+ Examples:
44
+ $ bead deployment generate lists.jsonl items.jsonl experiment/
45
+ $ bead deployment export-jatos experiment/ study.jzip \\
46
+ --title "My Study"
47
+ $ bead deployment upload-jatos study.jzip \\
48
+ --jatos-url https://jatos.example.com --api-token TOKEN
49
+ $ bead deployment validate experiment/
50
+ """
51
+
52
+
53
+ @click.command()
54
+ @click.argument(
55
+ "lists_file", type=click.Path(exists=True, dir_okay=False, path_type=Path)
56
+ )
57
+ @click.argument(
58
+ "items_file", type=click.Path(exists=True, dir_okay=False, path_type=Path)
59
+ )
60
+ @click.argument("output_dir", type=click.Path(path_type=Path))
61
+ @click.option(
62
+ "--experiment-type",
63
+ type=click.Choice(["likert_rating", "forced_choice", "magnitude_estimation"]),
64
+ default="likert_rating",
65
+ help="Type of experiment",
66
+ )
67
+ @click.option("--title", default="Experiment", help="Experiment title")
68
+ @click.option("--description", default="", help="Experiment description")
69
+ @click.option(
70
+ "--instructions", default="Please complete the task.", help="Instructions text"
71
+ )
72
+ @click.option(
73
+ "--distribution-strategy",
74
+ type=click.Choice(
75
+ [
76
+ "random",
77
+ "sequential",
78
+ "balanced",
79
+ "latin_square",
80
+ "stratified",
81
+ "weighted_random",
82
+ "quota_based",
83
+ "metadata_based",
84
+ ],
85
+ case_sensitive=False,
86
+ ),
87
+ required=True,
88
+ help="List distribution strategy (REQUIRED, no default). "
89
+ "random: Random selection. "
90
+ "sequential: Round-robin. "
91
+ "balanced: Assign to least-used list. "
92
+ "latin_square: Counterbalancing. "
93
+ "stratified: Balance across factors. "
94
+ "weighted_random: Non-uniform probabilities. "
95
+ "quota_based: Fixed quota per list. "
96
+ "metadata_based: Filter/rank by metadata.",
97
+ )
98
+ @click.option(
99
+ "--distribution-config",
100
+ type=str,
101
+ help="Strategy-specific configuration (JSON format). "
102
+ "Examples: "
103
+ 'quota_based: \'{"participants_per_list": 25, "allow_overflow": false}\'. '
104
+ 'weighted_random: \'{"weight_expression": "list_metadata.priority || 1.0"}\'. '
105
+ 'stratified: \'{"factors": ["condition", "verb_type"]}\'. '
106
+ "metadata_based: "
107
+ "'{\"filter_expression\": \"list_metadata.difficulty === 'easy'\"}'. ",
108
+ )
109
+ @click.option(
110
+ "--max-participants",
111
+ type=int,
112
+ help="Maximum total participants across all lists (unlimited if not specified)",
113
+ )
114
+ @click.option(
115
+ "--debug-mode",
116
+ is_flag=True,
117
+ help="Enable debug mode (always assign same list for testing)",
118
+ )
119
+ @click.option(
120
+ "--debug-list-index",
121
+ type=int,
122
+ default=0,
123
+ help="List index to use in debug mode (default: 0)",
124
+ )
125
+ @click.option(
126
+ "--dry-run",
127
+ is_flag=True,
128
+ help="Show what would be done without generating files",
129
+ )
130
+ @click.pass_context
131
+ def generate(
132
+ ctx: click.Context,
133
+ lists_file: Path,
134
+ items_file: Path,
135
+ output_dir: Path,
136
+ experiment_type: str,
137
+ title: str,
138
+ description: str,
139
+ instructions: str,
140
+ distribution_strategy: str,
141
+ distribution_config: str | None,
142
+ max_participants: int | None,
143
+ debug_mode: bool,
144
+ debug_list_index: int,
145
+ dry_run: bool,
146
+ ) -> None:
147
+ r"""Generate jsPsych experiment from lists and items.
148
+
149
+ Parameters
150
+ ----------
151
+ ctx : click.Context
152
+ Click context object.
153
+ lists_file : Path
154
+ JSONL file containing experiment lists (one list per line).
155
+ items_file : Path
156
+ JSONL file containing items (one item per line).
157
+ output_dir : Path
158
+ Output directory for generated experiment.
159
+ experiment_type : str
160
+ Type of experiment to generate.
161
+ title : str
162
+ Experiment title.
163
+ description : str
164
+ Experiment description.
165
+ instructions : str
166
+ Instructions text.
167
+ distribution_strategy : str
168
+ Distribution strategy type (required).
169
+ distribution_config : str | None
170
+ Strategy-specific configuration as JSON string.
171
+ max_participants : int | None
172
+ Maximum total participants.
173
+ debug_mode : bool
174
+ Enable debug mode.
175
+ debug_list_index : int
176
+ List index for debug mode.
177
+ dry_run : bool
178
+ Show what would be done without generating files.
179
+
180
+ Examples
181
+ --------
182
+ # Basic balanced distribution
183
+ $ bead deployment generate lists.jsonl items.jsonl experiment/ \\
184
+ --experiment-type forced_choice \\
185
+ --title "Acceptability Study" \\
186
+ --distribution-strategy balanced
187
+
188
+ # Quota-based with config
189
+ $ bead deployment generate lists.jsonl items.jsonl experiment/ \\
190
+ --experiment-type forced_choice \\
191
+ --distribution-strategy quota_based \\
192
+ --distribution-config '{"participants_per_list": 25, "allow_overflow": false}'
193
+
194
+ # Stratified by factors
195
+ $ bead deployment generate lists.jsonl items.jsonl experiment/ \\
196
+ --experiment-type forced_choice \\
197
+ --distribution-strategy stratified \\
198
+ --distribution-config '{"factors": ["condition", "verb_type"]}'
199
+
200
+ # Dry run to preview
201
+ $ bead deployment generate lists.jsonl items.jsonl experiment/ \\
202
+ --experiment-type forced_choice \\
203
+ --distribution-strategy balanced \\
204
+ --dry-run
205
+ """
206
+ try:
207
+ # Parse distribution config if provided
208
+ strategy_config_dict: dict[str, JsonValue] = {}
209
+ if distribution_config:
210
+ try:
211
+ strategy_config_dict = json.loads(distribution_config)
212
+ except json.JSONDecodeError as e:
213
+ print_error(
214
+ f"Invalid JSON in --distribution-config: {e}\n"
215
+ f"Provided: {distribution_config}\n"
216
+ f"Example: '{{\"participants_per_list\": 25}}'"
217
+ )
218
+ ctx.exit(1)
219
+
220
+ # Create distribution strategy
221
+ try:
222
+ dist_strategy = ListDistributionStrategy(
223
+ strategy_type=DistributionStrategyType(distribution_strategy),
224
+ strategy_config=strategy_config_dict,
225
+ max_participants=max_participants,
226
+ debug_mode=debug_mode,
227
+ debug_list_index=debug_list_index,
228
+ )
229
+ except ValueError as e:
230
+ print_error(f"Invalid distribution strategy configuration: {e}")
231
+ ctx.exit(1)
232
+ # Load experiment lists from JSONL file (one list per line)
233
+ print_info(f"Loading experiment lists from {lists_file}")
234
+ experiment_lists: list[ExperimentList] = []
235
+ with open(lists_file, encoding="utf-8") as f:
236
+ for line in f:
237
+ line = line.strip()
238
+ if not line:
239
+ continue
240
+ list_data = json.loads(line)
241
+ exp_list = ExperimentList(**list_data)
242
+ experiment_lists.append(exp_list)
243
+
244
+ if not experiment_lists:
245
+ print_error(f"No lists found in {lists_file}")
246
+ ctx.exit(1)
247
+
248
+ print_info(f"Loaded {len(experiment_lists)} experiment lists")
249
+
250
+ # Load items
251
+ print_info(f"Loading items from {items_file}")
252
+ items_dict: dict[UUID, Item] = {}
253
+ with open(items_file, encoding="utf-8") as f:
254
+ for line in f:
255
+ line = line.strip()
256
+ if not line:
257
+ continue
258
+ item_data = json.loads(line)
259
+ item = Item(**item_data)
260
+ items_dict[item.id] = item
261
+
262
+ print_info(f"Loaded {len(items_dict)} items")
263
+
264
+ # Create stub templates for each unique item_template_id (simplified for CLI)
265
+ # Extract unique template IDs from items
266
+ unique_template_ids = {item.item_template_id for item in items_dict.values()}
267
+ templates_dict: dict[UUID, ItemTemplate] = {}
268
+ for template_id in unique_template_ids:
269
+ # Create minimal stub template (no actual template
270
+ # structure needed for deployment)
271
+ templates_dict[template_id] = ItemTemplate(
272
+ id=template_id,
273
+ name=f"template_{template_id}",
274
+ description="Auto-generated stub template for CLI deployment",
275
+ judgment_type="acceptability",
276
+ task_type="ordinal_scale",
277
+ task_spec=TaskSpec(
278
+ prompt="Rate this item.",
279
+ scale_bounds=(1, 7),
280
+ ),
281
+ presentation_spec=PresentationSpec(mode="static"),
282
+ )
283
+
284
+ print_info(f"Created {len(templates_dict)} stub templates for deployment")
285
+
286
+ # Create experiment config with distribution strategy
287
+ config = ExperimentConfig(
288
+ experiment_type=experiment_type, # type: ignore
289
+ title=title,
290
+ description=description,
291
+ instructions=instructions,
292
+ distribution_strategy=dist_strategy,
293
+ )
294
+
295
+ # Generate experiment (or show dry-run preview)
296
+ if dry_run:
297
+ print_info("[DRY RUN] Would generate jsPsych experiment with:")
298
+ console.print(f" [dim]Output directory:[/dim] {output_dir}")
299
+ console.print(f" [dim]Experiment type:[/dim] {experiment_type}")
300
+ console.print(f" [dim]Title:[/dim] {title}")
301
+ console.print(
302
+ f" [dim]Distribution strategy:[/dim] {distribution_strategy}"
303
+ )
304
+ console.print(f" [dim]Number of lists:[/dim] {len(experiment_lists)}")
305
+ console.print(f" [dim]Number of items:[/dim] {len(items_dict)}")
306
+ console.print(f" [dim]Number of templates:[/dim] {len(templates_dict)}")
307
+ if max_participants:
308
+ console.print(f" [dim]Max participants:[/dim] {max_participants}")
309
+ if debug_mode:
310
+ console.print(
311
+ f" [dim]Debug mode:[/dim] Enabled (list index: {debug_list_index})"
312
+ )
313
+ print_info("[DRY RUN] Files that would be created:")
314
+ console.print(f" [dim]{output_dir}/index.html[/dim]")
315
+ console.print(f" [dim]{output_dir}/js/experiment.js[/dim]")
316
+ console.print(f" [dim]{output_dir}/js/list_distributor.js[/dim]")
317
+ console.print(f" [dim]{output_dir}/css/experiment.css[/dim]")
318
+ console.print(f" [dim]{output_dir}/data/config.json[/dim]")
319
+ console.print(f" [dim]{output_dir}/data/lists.jsonl[/dim]")
320
+ console.print(f" [dim]{output_dir}/data/items.jsonl[/dim]")
321
+ console.print(f" [dim]{output_dir}/data/distribution.json[/dim]")
322
+ else:
323
+ with Progress(
324
+ SpinnerColumn(),
325
+ TextColumn("[progress.description]{task.description}"),
326
+ console=console,
327
+ ) as progress:
328
+ progress.add_task("Generating jsPsych experiment...", total=None)
329
+
330
+ generator = JsPsychExperimentGenerator(
331
+ config=config,
332
+ output_dir=output_dir,
333
+ )
334
+ output_path = generator.generate(
335
+ lists=experiment_lists,
336
+ items=items_dict,
337
+ templates=templates_dict,
338
+ )
339
+
340
+ print_success(f"Generated jsPsych experiment: {output_path}")
341
+
342
+ except ValidationError as e:
343
+ print_error(f"Validation error: {e}")
344
+ ctx.exit(1)
345
+ except Exception as e:
346
+ print_error(f"Failed to generate experiment: {e}")
347
+ ctx.exit(1)
348
+
349
+
350
+ @click.command()
351
+ @click.argument(
352
+ "experiment_dir", type=click.Path(exists=True, file_okay=False, path_type=Path)
353
+ )
354
+ @click.argument("output_file", type=click.Path(path_type=Path))
355
+ @click.option("--title", required=True, help="Study title for JATOS")
356
+ @click.option("--description", default="", help="Study description")
357
+ @click.option("--component-title", default="Main Experiment", help="Component title")
358
+ @click.pass_context
359
+ def export_jatos(
360
+ ctx: click.Context,
361
+ experiment_dir: Path,
362
+ output_file: Path,
363
+ title: str,
364
+ description: str,
365
+ component_title: str,
366
+ ) -> None:
367
+ r"""Export experiment to JATOS .jzip file.
368
+
369
+ Parameters
370
+ ----------
371
+ ctx : click.Context
372
+ Click context object.
373
+ experiment_dir : Path
374
+ Directory containing generated experiment.
375
+ output_file : Path
376
+ Output path for .jzip file.
377
+ title : str
378
+ Study title for JATOS.
379
+ description : str
380
+ Study description.
381
+ component_title : str
382
+ Component title.
383
+
384
+ Examples
385
+ --------
386
+ $ bead deployment export-jatos experiment/ study.jzip \\
387
+ --title "Acceptability Study" \\
388
+ --description "Rating task for linguistic acceptability"
389
+ """
390
+ try:
391
+ print_info(f"Exporting experiment from {experiment_dir}")
392
+
393
+ with Progress(
394
+ SpinnerColumn(),
395
+ TextColumn("[progress.description]{task.description}"),
396
+ console=console,
397
+ ) as progress:
398
+ progress.add_task("Creating JATOS package...", total=None)
399
+
400
+ exporter = JATOSExporter(
401
+ study_title=title,
402
+ study_description=description,
403
+ )
404
+ exporter.export(
405
+ experiment_dir=experiment_dir,
406
+ output_path=output_file,
407
+ component_title=component_title,
408
+ )
409
+
410
+ print_success(f"Created JATOS package: {output_file}")
411
+
412
+ except FileNotFoundError as e:
413
+ print_error(f"File not found: {e}")
414
+ ctx.exit(1)
415
+ except ValueError as e:
416
+ print_error(f"Invalid experiment: {e}")
417
+ ctx.exit(1)
418
+ except Exception as e:
419
+ print_error(f"Failed to export to JATOS: {e}")
420
+ ctx.exit(1)
421
+
422
+
423
+ @click.command()
424
+ @click.argument("jzip_file", type=click.Path(exists=True, path_type=Path))
425
+ @click.option("--jatos-url", required=True, help="JATOS server URL")
426
+ @click.option("--api-token", required=True, help="JATOS API token")
427
+ @click.pass_context
428
+ def upload_jatos(
429
+ ctx: click.Context,
430
+ jzip_file: Path,
431
+ jatos_url: str,
432
+ api_token: str,
433
+ ) -> None:
434
+ r"""Upload .jzip file to JATOS server.
435
+
436
+ Parameters
437
+ ----------
438
+ ctx : click.Context
439
+ Click context object.
440
+ jzip_file : Path
441
+ Path to .jzip file.
442
+ jatos_url : str
443
+ JATOS server URL.
444
+ api_token : str
445
+ JATOS API token.
446
+
447
+ Examples
448
+ --------
449
+ $ bead deployment upload-jatos study.jzip \\
450
+ --jatos-url https://jatos.example.com \\
451
+ --api-token my-api-token
452
+ """
453
+ try:
454
+ print_info(f"Uploading {jzip_file} to {jatos_url}")
455
+
456
+ with Progress(
457
+ SpinnerColumn(),
458
+ TextColumn("[progress.description]{task.description}"),
459
+ console=console,
460
+ ) as progress:
461
+ progress.add_task("Uploading to JATOS...", total=None)
462
+
463
+ client = JATOSClient(base_url=jatos_url, api_token=api_token)
464
+ study_id: int = client.import_study(jzip_file) # type: ignore[attr-defined]
465
+
466
+ print_success(f"Uploaded study to JATOS (Study ID: {study_id})")
467
+
468
+ except Exception as e:
469
+ print_error(f"Failed to upload to JATOS: {e}")
470
+ ctx.exit(1)
471
+
472
+
473
+ @click.command()
474
+ @click.argument(
475
+ "experiment_dir", type=click.Path(exists=True, file_okay=False, path_type=Path)
476
+ )
477
+ @click.option(
478
+ "--check-distribution",
479
+ is_flag=True,
480
+ default=False,
481
+ help="Validate distribution strategy configuration",
482
+ )
483
+ @click.option(
484
+ "--check-trials",
485
+ is_flag=True,
486
+ default=False,
487
+ help="Validate trial configurations (if present)",
488
+ )
489
+ @click.option(
490
+ "--check-data-structure",
491
+ is_flag=True,
492
+ default=False,
493
+ help="Validate JSONL data structure and schemas",
494
+ )
495
+ @click.option(
496
+ "--strict",
497
+ is_flag=True,
498
+ default=False,
499
+ help="Enable all validation checks (strict mode)",
500
+ )
501
+ @click.pass_context
502
+ def validate(
503
+ ctx: click.Context,
504
+ experiment_dir: Path,
505
+ check_distribution: bool,
506
+ check_trials: bool,
507
+ check_data_structure: bool,
508
+ strict: bool,
509
+ ) -> None:
510
+ """Validate generated experiment structure.
511
+
512
+ Parameters
513
+ ----------
514
+ ctx : click.Context
515
+ Click context object.
516
+ experiment_dir : Path
517
+ Directory containing generated experiment.
518
+ check_distribution : bool
519
+ Validate distribution strategy configuration.
520
+ check_trials : bool
521
+ Validate trial configurations (if present).
522
+ check_data_structure : bool
523
+ Validate JSONL data structure and schemas.
524
+ strict : bool
525
+ Enable all validation checks.
526
+
527
+ Examples
528
+ --------
529
+ $ bead deployment validate experiment/
530
+
531
+ $ bead deployment validate experiment/ --check-distribution
532
+
533
+ $ bead deployment validate experiment/ --strict
534
+ """
535
+ try:
536
+ # Enable all checks if strict mode is enabled
537
+ if strict:
538
+ check_distribution = True
539
+ check_trials = True
540
+ check_data_structure = True
541
+
542
+ print_info(f"Validating experiment: {experiment_dir}")
543
+ if strict:
544
+ print_info("Running in strict mode (all checks enabled)")
545
+
546
+ validation_errors: list[str] = []
547
+ validation_warnings: list[str] = []
548
+
549
+ # Check required files (batch mode)
550
+ required_files = [
551
+ "index.html",
552
+ "css/experiment.css",
553
+ "js/experiment.js",
554
+ "js/list_distributor.js",
555
+ "data/config.json",
556
+ "data/lists.jsonl",
557
+ "data/items.jsonl",
558
+ "data/distribution.json",
559
+ ]
560
+
561
+ missing_files: list[str] = []
562
+ for file_path in required_files:
563
+ full_path = experiment_dir / file_path
564
+ if not full_path.exists():
565
+ missing_files.append(file_path)
566
+
567
+ if missing_files:
568
+ for file_path in missing_files:
569
+ validation_errors.append(f"Missing required file: {file_path}")
570
+
571
+ # Validate lists.jsonl
572
+ lists_file = experiment_dir / "data" / "lists.jsonl"
573
+ if lists_file.exists():
574
+ with open(lists_file, encoding="utf-8") as f:
575
+ lists_data = [json.loads(line) for line in f if line.strip()]
576
+
577
+ if not lists_data:
578
+ validation_errors.append("lists.jsonl must contain at least one list")
579
+ else:
580
+ lists_data = []
581
+
582
+ # Validate items.jsonl
583
+ items_file = experiment_dir / "data" / "items.jsonl"
584
+ if items_file.exists():
585
+ with open(items_file, encoding="utf-8") as f:
586
+ items_data = [json.loads(line) for line in f if line.strip()]
587
+
588
+ if not items_data:
589
+ validation_errors.append("items.jsonl must contain at least one item")
590
+ else:
591
+ items_data = []
592
+
593
+ # Validate distribution.json
594
+ dist_file = experiment_dir / "data" / "distribution.json"
595
+ dist_data: dict[str, JsonValue] | None = None
596
+ if dist_file.exists():
597
+ with open(dist_file, encoding="utf-8") as f:
598
+ dist_data_obj: JsonValue = json.load(f)
599
+ if isinstance(dist_data_obj, dict):
600
+ dist_data = dist_data_obj # type: ignore[assignment]
601
+
602
+ if dist_data is None or "strategy_type" not in dist_data:
603
+ validation_errors.append(
604
+ "distribution.json must be a dict with strategy_type field"
605
+ )
606
+
607
+ # Additional validation checks
608
+ if check_distribution and dist_data:
609
+ _validate_distribution_config(
610
+ dist_data, validation_errors, validation_warnings
611
+ )
612
+
613
+ if check_trials:
614
+ _validate_trial_configs(
615
+ experiment_dir, validation_errors, validation_warnings
616
+ )
617
+
618
+ if check_data_structure and items_data and lists_data:
619
+ _validate_data_structure(
620
+ items_data, lists_data, validation_errors, validation_warnings
621
+ )
622
+
623
+ # Report results
624
+ if validation_errors:
625
+ print_error(f"Validation failed with {len(validation_errors)} error(s):")
626
+ for error in validation_errors:
627
+ console.print(f" [red]✗[/red] {error}")
628
+ if validation_warnings:
629
+ console.print()
630
+ console.print(
631
+ f"[yellow]⚠[/yellow] {len(validation_warnings)} warning(s):"
632
+ )
633
+ for warning in validation_warnings:
634
+ console.print(f" [yellow]⚠[/yellow] {warning}")
635
+ ctx.exit(1)
636
+
637
+ if validation_warnings:
638
+ console.print(f"[yellow]⚠[/yellow] {len(validation_warnings)} warning(s):")
639
+ for warning in validation_warnings:
640
+ console.print(f" [yellow]⚠[/yellow] {warning}")
641
+
642
+ print_success(
643
+ f"Experiment structure is valid "
644
+ f"({len(lists_data)} lists, {len(items_data)} items)"
645
+ )
646
+
647
+ except json.JSONDecodeError as e:
648
+ print_error(f"Invalid JSON in experiment data files: {e}")
649
+ ctx.exit(1)
650
+ except Exception as e:
651
+ print_error(f"Failed to validate experiment: {e}")
652
+ ctx.exit(1)
653
+
654
+
655
+ def _validate_distribution_config(
656
+ dist_data: dict[str, JsonValue],
657
+ errors: list[str],
658
+ warnings: list[str],
659
+ ) -> None:
660
+ """Validate distribution strategy configuration.
661
+
662
+ Parameters
663
+ ----------
664
+ dist_data : dict[str, JsonValue]
665
+ Distribution configuration data.
666
+ errors : list[str]
667
+ List to append errors to.
668
+ warnings : list[str]
669
+ List to append warnings to.
670
+ """
671
+ strategy_type = dist_data.get("strategy_type")
672
+
673
+ # Validate strategy type
674
+ valid_strategies = [
675
+ "random",
676
+ "sequential",
677
+ "balanced",
678
+ "latin_square",
679
+ "stratified",
680
+ "weighted_random",
681
+ "quota_based",
682
+ "metadata_based",
683
+ ]
684
+
685
+ if strategy_type not in valid_strategies:
686
+ errors.append(
687
+ f"Invalid strategy_type: {strategy_type}. "
688
+ f"Must be one of: {', '.join(valid_strategies)}"
689
+ )
690
+ return
691
+
692
+ # Validate strategy-specific configuration
693
+ strategy_config_raw = dist_data.get("strategy_config")
694
+ strategy_config: dict[str, JsonValue] | None = (
695
+ strategy_config_raw if isinstance(strategy_config_raw, dict) else None
696
+ )
697
+
698
+ if strategy_type == "quota_based":
699
+ if not strategy_config:
700
+ errors.append("quota_based strategy requires strategy_config")
701
+ elif "participants_per_list" not in strategy_config:
702
+ errors.append(
703
+ "quota_based requires participants_per_list in strategy_config"
704
+ )
705
+
706
+ elif strategy_type == "weighted_random":
707
+ if strategy_config and "weight_expression" not in strategy_config:
708
+ warnings.append(
709
+ "weighted_random without weight_expression uses uniform weights"
710
+ )
711
+
712
+ elif strategy_type == "metadata_based":
713
+ if not strategy_config:
714
+ errors.append("metadata_based strategy requires strategy_config")
715
+ elif (
716
+ "filter_expression" not in strategy_config
717
+ and "rank_expression" not in strategy_config
718
+ ):
719
+ warnings.append(
720
+ "metadata_based without filter or rank expressions has no effect"
721
+ )
722
+
723
+ elif strategy_type == "stratified":
724
+ if not strategy_config:
725
+ warnings.append(
726
+ "stratified strategy without factors uses random assignment"
727
+ )
728
+ elif "factors" not in strategy_config:
729
+ warnings.append(
730
+ "stratified strategy without factors uses random assignment"
731
+ )
732
+
733
+
734
+ def _validate_trial_configs(
735
+ experiment_dir: Path,
736
+ errors: list[str],
737
+ warnings: list[str],
738
+ ) -> None:
739
+ """Validate trial configuration files if present.
740
+
741
+ Parameters
742
+ ----------
743
+ experiment_dir : Path
744
+ Experiment directory.
745
+ errors : list[str]
746
+ List to append errors to.
747
+ warnings : list[str]
748
+ List to append warnings to.
749
+ """
750
+ # Check for trial configuration files
751
+ config_dir = experiment_dir / "config"
752
+ if not config_dir.exists():
753
+ return # No config directory, skip trial validation
754
+
755
+ trial_configs = list(config_dir.glob("*_config.json"))
756
+
757
+ for config_file in trial_configs:
758
+ try:
759
+ config_data: JsonValue = json.loads(config_file.read_text(encoding="utf-8"))
760
+
761
+ if not isinstance(config_data, dict):
762
+ errors.append(f"Trial config {config_file.name} must be a JSON object")
763
+ continue
764
+
765
+ config_dict: dict[str, JsonValue] = config_data # type: ignore[assignment]
766
+
767
+ # Validate config type
768
+ config_type = config_dict.get("type")
769
+ if not config_type:
770
+ errors.append(f"Trial config {config_file.name} missing 'type' field")
771
+ continue
772
+
773
+ # Validate type-specific fields
774
+ if config_type == "rating_scale":
775
+ required_fields = ["min_value", "max_value", "step"]
776
+ for field in required_fields:
777
+ if field not in config_dict:
778
+ errors.append(
779
+ f"Rating config {config_file.name} missing '{field}' field"
780
+ )
781
+
782
+ elif config_type == "choice":
783
+ if "button_html" not in config_dict:
784
+ warnings.append(
785
+ f"Choice config {config_file.name} missing button_html "
786
+ f"(will use default)"
787
+ )
788
+
789
+ except json.JSONDecodeError:
790
+ errors.append(f"Trial config {config_file.name} contains invalid JSON")
791
+
792
+
793
+ def _validate_data_structure(
794
+ items_data: list[dict[str, JsonValue]],
795
+ lists_data: list[dict[str, JsonValue]],
796
+ errors: list[str],
797
+ warnings: list[str],
798
+ ) -> None:
799
+ """Validate JSONL data structure and schemas.
800
+
801
+ Parameters
802
+ ----------
803
+ items_data : list[dict[str, JsonValue]]
804
+ Items data from items.jsonl.
805
+ lists_data : list[dict[str, JsonValue]]
806
+ Lists data from lists.jsonl.
807
+ errors : list[str]
808
+ List to append errors to.
809
+ warnings : list[str]
810
+ List to append warnings to.
811
+ """
812
+ # Validate items structure
813
+ for i, item in enumerate(items_data):
814
+ if "id" not in item:
815
+ errors.append(f"Item {i} missing 'id' field")
816
+
817
+ if "item_template_id" not in item:
818
+ warnings.append(f"Item {i} missing 'item_template_id' field")
819
+
820
+ if "rendered_elements" not in item:
821
+ errors.append(f"Item {i} missing 'rendered_elements' field")
822
+
823
+ # Validate lists structure
824
+ for i, exp_list in enumerate(lists_data):
825
+ if "id" not in exp_list:
826
+ errors.append(f"List {i} missing 'id' field")
827
+
828
+ if "item_refs" not in exp_list:
829
+ errors.append(f"List {i} missing 'item_refs' field")
830
+ elif not isinstance(exp_list["item_refs"], list):
831
+ errors.append(f"List {i} 'item_refs' must be a list")
832
+ elif not exp_list["item_refs"]:
833
+ warnings.append(f"List {i} has no items (empty item_refs)")
834
+
835
+ # Check that all item_refs in lists exist in items
836
+ item_ids = {item.get("id") for item in items_data if "id" in item}
837
+
838
+ for i, exp_list in enumerate(lists_data):
839
+ if "item_refs" in exp_list and isinstance(exp_list["item_refs"], list):
840
+ # item_refs are UUID strings from JSON
841
+ item_refs_list = cast(list[str], exp_list["item_refs"])
842
+ for item_ref in item_refs_list:
843
+ if item_ref not in item_ids:
844
+ errors.append(f"List {i} references non-existent item: {item_ref}")
845
+
846
+
847
+ # Import nested command groups
848
+ from bead.cli.deployment_trials import deployment_trials # noqa: E402
849
+ from bead.cli.deployment_ui import deployment_ui # noqa: E402
850
+
851
+ # Register commands
852
+ deployment.add_command(generate)
853
+ deployment.add_command(export_jatos)
854
+ deployment.add_command(upload_jatos)
855
+ deployment.add_command(validate)
856
+
857
+ # Register nested command groups
858
+ deployment.add_command(deployment_trials)
859
+ deployment.add_command(deployment_ui)