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,598 @@
1
+ """jsPsych batch experiment generator.
2
+
3
+ Generates complete jsPsych 8.x experiments using JATOS batch sessions for
4
+ server-side list distribution.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from uuid import UUID
12
+
13
+ from jinja2 import Environment, FileSystemLoader
14
+
15
+ from bead.data.base import JsonValue
16
+ from bead.data.serialization import SerializationError, write_jsonlines
17
+ from bead.deployment.jspsych.config import (
18
+ ChoiceConfig,
19
+ ExperimentConfig,
20
+ InstructionsConfig,
21
+ RatingScaleConfig,
22
+ )
23
+ from bead.deployment.jspsych.trials import create_trial
24
+ from bead.items.item import Item
25
+ from bead.items.item_template import ItemTemplate
26
+ from bead.lists import ExperimentList
27
+
28
+
29
+ class JsPsychExperimentGenerator:
30
+ """Generator for jsPsych 8.x experiments.
31
+
32
+ This class orchestrates the generation of complete jsPsych experiments,
33
+ including HTML, CSS, JavaScript, and data files. It converts bead's
34
+ ExperimentList and Item models into a deployable jsPsych experiment.
35
+
36
+ Parameters
37
+ ----------
38
+ config : ExperimentConfig
39
+ Experiment configuration.
40
+ output_dir : Path
41
+ Output directory for generated files.
42
+ rating_config : RatingScaleConfig | None
43
+ Configuration for rating scale trials (required for rating experiments).
44
+ Defaults to RatingScaleConfig() if not provided.
45
+ choice_config : ChoiceConfig | None
46
+ Configuration for choice trials (required for choice experiments).
47
+ Defaults to ChoiceConfig() if not provided.
48
+
49
+ Attributes
50
+ ----------
51
+ config : ExperimentConfig
52
+ Experiment configuration.
53
+ output_dir : Path
54
+ Output directory for generated files.
55
+ rating_config : RatingScaleConfig
56
+ Configuration for rating scale trials.
57
+ choice_config : ChoiceConfig
58
+ Configuration for choice trials.
59
+ jinja_env : Environment
60
+ Jinja2 environment for template rendering.
61
+
62
+ Examples
63
+ --------
64
+ >>> from pathlib import Path
65
+ >>> config = ExperimentConfig(
66
+ ... experiment_type="likert_rating",
67
+ ... title="Acceptability Study",
68
+ ... description="Rate sentences",
69
+ ... instructions="Rate each sentence from 1 to 7"
70
+ ... )
71
+ >>> generator = JsPsychExperimentGenerator(
72
+ ... config=config,
73
+ ... output_dir=Path("/tmp/experiment")
74
+ ... )
75
+ >>> # generator.generate(lists, items)
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ config: ExperimentConfig,
81
+ output_dir: Path,
82
+ rating_config: RatingScaleConfig | None = None,
83
+ choice_config: ChoiceConfig | None = None,
84
+ ) -> None:
85
+ self.config = config
86
+ self.output_dir = Path(output_dir)
87
+ self.rating_config = rating_config or RatingScaleConfig()
88
+ self.choice_config = choice_config or ChoiceConfig()
89
+
90
+ # Setup Jinja2 environment
91
+ template_dir = Path(__file__).parent / "templates"
92
+ self.jinja_env = Environment(loader=FileSystemLoader(str(template_dir)))
93
+
94
+ def generate(
95
+ self,
96
+ lists: list[ExperimentList],
97
+ items: dict[UUID, Item],
98
+ templates: dict[UUID, ItemTemplate],
99
+ ) -> Path:
100
+ """Generate complete jsPsych batch experiment.
101
+
102
+ Creates a unified batch experiment that uses JATOS batch sessions for
103
+ server-side list distribution. All participants are automatically assigned
104
+ to lists according to the distribution strategy specified in the experiment
105
+ configuration.
106
+
107
+ Parameters
108
+ ----------
109
+ lists : list[ExperimentList]
110
+ Experiment lists for batch distribution (required, must be non-empty).
111
+ All lists will be serialized to lists.jsonl and made available for
112
+ participant assignment.
113
+ items : dict[UUID, Item]
114
+ Dictionary of items keyed by UUID (required, must be non-empty).
115
+ All items referenced by lists must be present in this dictionary.
116
+ templates : dict[UUID, ItemTemplate]
117
+ Dictionary of item templates keyed by UUID (required, must be non-empty).
118
+ All templates referenced by items must be present in this dictionary.
119
+
120
+ Returns
121
+ -------
122
+ Path
123
+ Path to the generated experiment directory containing:
124
+ - index.html
125
+ - js/experiment.js, js/list_distributor.js
126
+ - css/experiment.css
127
+ - data/config.json, data/lists.jsonl, data/items.jsonl,
128
+ data/distribution.json
129
+
130
+ Raises
131
+ ------
132
+ ValueError
133
+ If lists is empty, items is empty, templates is empty, or if any
134
+ referenced UUIDs are not found in the provided dictionaries.
135
+ SerializationError
136
+ If writing JSONL files fails.
137
+
138
+ Examples
139
+ --------
140
+ >>> from pathlib import Path
141
+ >>> from bead.deployment.distribution import (
142
+ ... ListDistributionStrategy, DistributionStrategyType
143
+ ... )
144
+ >>> strategy = ListDistributionStrategy(
145
+ ... strategy_type=DistributionStrategyType.BALANCED
146
+ ... )
147
+ >>> config = ExperimentConfig(
148
+ ... experiment_type="forced_choice",
149
+ ... title="Test",
150
+ ... description="Test",
151
+ ... instructions="Test",
152
+ ... distribution_strategy=strategy
153
+ ... )
154
+ >>> generator = JsPsychExperimentGenerator(
155
+ ... config=config, output_dir=Path("/tmp/exp")
156
+ ... )
157
+ >>> # output_dir = generator.generate(lists, items, templates)
158
+ """
159
+ # Validate inputs (no fallbacks)
160
+ if not lists:
161
+ raise ValueError(
162
+ "generate() requires at least one ExperimentList. Got empty list."
163
+ " Create lists using ListPartitioner before calling generate()."
164
+ " Example: partitioner.partition_with_batch_constraints(...)"
165
+ )
166
+
167
+ if not items:
168
+ raise ValueError(
169
+ "generate() requires items dictionary. Got empty dict."
170
+ " Ensure items are constructed before calling generate()."
171
+ " Items must be created using bead.items utilities."
172
+ )
173
+
174
+ if not templates:
175
+ raise ValueError(
176
+ "generate() requires templates dictionary. Got empty dict. "
177
+ "Ensure item templates are included. If items don't use templates, "
178
+ "provide an empty template: {item.item_template_id: ItemTemplate(...)}."
179
+ )
180
+
181
+ # Validate all item references can be resolved
182
+ self._validate_item_references(lists, items)
183
+
184
+ # Validate all template references can be resolved
185
+ self._validate_template_references(items, templates)
186
+
187
+ # Create directory structure
188
+ self._create_directory_structure()
189
+
190
+ # Write batch data files (lists, items, distribution config, trials)
191
+ self._write_lists_jsonl(lists)
192
+ self._write_items_jsonl(items)
193
+ self._write_distribution_config()
194
+ self._write_trials_json(lists, items, templates)
195
+
196
+ # Generate HTML/CSS/JS files
197
+ self._generate_html()
198
+ self._generate_css()
199
+ self._generate_experiment_script()
200
+ self._generate_config_file()
201
+ self._copy_list_distributor_script()
202
+
203
+ # Copy slopit bundle if enabled
204
+ if self.config.slopit.enabled:
205
+ self._copy_slopit_bundle()
206
+
207
+ return self.output_dir
208
+
209
+ def _validate_item_references(
210
+ self,
211
+ lists: list[ExperimentList],
212
+ items: dict[UUID, Item],
213
+ ) -> None:
214
+ """Validate all item UUIDs in lists can be resolved.
215
+
216
+ Parameters
217
+ ----------
218
+ lists : list[ExperimentList]
219
+ Lists to validate.
220
+ items : dict[UUID, Item]
221
+ Items dictionary.
222
+
223
+ Raises
224
+ ------
225
+ ValueError
226
+ If any item UUID in lists is not found in items dict.
227
+ """
228
+ for exp_list in lists:
229
+ for item_id in exp_list.item_refs:
230
+ if item_id not in items:
231
+ available_sample = list(items.keys())[:5]
232
+ ellipsis = "..." if len(items) > 5 else ""
233
+ raise ValueError(
234
+ f"Item {item_id} referenced in list '{exp_list.name}' "
235
+ f"(list_number={exp_list.list_number}) not found in items. "
236
+ f"Available UUIDs (first 5): {available_sample}{ellipsis}. "
237
+ f"Include all referenced items in items dict."
238
+ )
239
+
240
+ def _validate_template_references(
241
+ self,
242
+ items: dict[UUID, Item],
243
+ templates: dict[UUID, ItemTemplate],
244
+ ) -> None:
245
+ """Validate all template UUIDs in items can be resolved.
246
+
247
+ Parameters
248
+ ----------
249
+ items : dict[UUID, Item]
250
+ Items dictionary.
251
+ templates : dict[UUID, ItemTemplate]
252
+ Templates dictionary.
253
+
254
+ Raises
255
+ ------
256
+ ValueError
257
+ If any template UUID in items is not found in templates dict.
258
+ """
259
+ for item_id, item in items.items():
260
+ if item.item_template_id not in templates:
261
+ available_sample = list(templates.keys())[:5]
262
+ ellipsis = "..." if len(templates) > 5 else ""
263
+ raise ValueError(
264
+ f"Template {item.item_template_id} for item {item_id} "
265
+ f"not found in templates. "
266
+ f"Available UUIDs (first 5): {available_sample}{ellipsis}. "
267
+ f"Include all referenced templates in templates dict."
268
+ )
269
+
270
+ def _write_lists_jsonl(self, lists: list[ExperimentList]) -> None:
271
+ """Write experiment lists to data/lists.jsonl.
272
+
273
+ Parameters
274
+ ----------
275
+ lists : list[ExperimentList]
276
+ Lists to serialize.
277
+
278
+ Raises
279
+ ------
280
+ SerializationError
281
+ If writing JSONL fails.
282
+ """
283
+ output_path = self.output_dir / "data" / "lists.jsonl"
284
+ try:
285
+ write_jsonlines(lists, output_path)
286
+ except SerializationError as e:
287
+ raise SerializationError(
288
+ f"Failed to write lists.jsonl to {output_path}: {e}. "
289
+ f"Check write permissions and disk space. "
290
+ f"Attempted to serialize {len(lists)} lists."
291
+ ) from e
292
+
293
+ def _write_items_jsonl(self, items: dict[UUID, Item]) -> None:
294
+ """Write items to data/items.jsonl.
295
+
296
+ Parameters
297
+ ----------
298
+ items : dict[UUID, Item]
299
+ Items dictionary to serialize.
300
+
301
+ Raises
302
+ ------
303
+ SerializationError
304
+ If writing JSONL fails.
305
+ """
306
+ output_path = self.output_dir / "data" / "items.jsonl"
307
+ try:
308
+ # Convert dict values to list for serialization
309
+ items_list = list(items.values())
310
+ write_jsonlines(items_list, output_path)
311
+ except SerializationError as e:
312
+ raise SerializationError(
313
+ f"Failed to write items.jsonl to {output_path}: {e}. "
314
+ f"Check write permissions and disk space. "
315
+ f"Attempted to serialize {len(items)} items."
316
+ ) from e
317
+
318
+ def _write_trials_json(
319
+ self,
320
+ lists: list[ExperimentList],
321
+ items: dict[UUID, Item],
322
+ templates: dict[UUID, ItemTemplate],
323
+ ) -> None:
324
+ """Write pre-generated trials to data/trials.json.
325
+
326
+ Creates trials for each list and stores them in a JSON file
327
+ keyed by list ID for efficient loading in the experiment.
328
+
329
+ Parameters
330
+ ----------
331
+ lists : list[ExperimentList]
332
+ Experiment lists.
333
+ items : dict[UUID, Item]
334
+ Items dictionary.
335
+ templates : dict[UUID, ItemTemplate]
336
+ Templates dictionary.
337
+
338
+ Raises
339
+ ------
340
+ SerializationError
341
+ If writing JSON fails.
342
+ """
343
+ output_path = self.output_dir / "data" / "trials.json"
344
+ trials_by_list: dict[str, list[dict[str, JsonValue]]] = {}
345
+
346
+ for exp_list in lists:
347
+ list_trials: list[dict[str, JsonValue]] = []
348
+ for trial_num, item_id in enumerate(exp_list.item_refs):
349
+ item = items[item_id]
350
+ template = templates[item.item_template_id]
351
+ trial = create_trial(
352
+ item=item,
353
+ template=template,
354
+ experiment_config=self.config,
355
+ trial_number=trial_num,
356
+ rating_config=self.rating_config,
357
+ choice_config=self.choice_config,
358
+ )
359
+ list_trials.append(trial)
360
+ trials_by_list[str(exp_list.id)] = list_trials
361
+
362
+ try:
363
+ with open(output_path, "w", encoding="utf-8") as f:
364
+ json.dump(trials_by_list, f, indent=2)
365
+ except Exception as e:
366
+ raise SerializationError(
367
+ f"Failed to write trials.json to {output_path}: {e}"
368
+ ) from e
369
+
370
+ def _write_distribution_config(self) -> None:
371
+ """Write distribution strategy config to data/distribution.json.
372
+
373
+ Raises
374
+ ------
375
+ SerializationError
376
+ If writing JSON fails.
377
+ """
378
+ output_path = self.output_dir / "data" / "distribution.json"
379
+ try:
380
+ # Use model_dump_json() to handle UUID serialization
381
+ json_str = self.config.distribution_strategy.model_dump_json(indent=2)
382
+ output_path.write_text(json_str)
383
+ except (OSError, TypeError) as e:
384
+ raise SerializationError(
385
+ f"Failed to write distribution.json to {output_path}: {e}. "
386
+ f"Check write permissions and disk space. "
387
+ f"Strategy type: {self.config.distribution_strategy.strategy_type}"
388
+ ) from e
389
+
390
+ def _copy_list_distributor_script(self) -> None:
391
+ """Copy list_distributor.js from compiled dist/ to js/ directory.
392
+
393
+ Raises
394
+ ------
395
+ FileNotFoundError
396
+ If list_distributor.js is not found in dist/.
397
+ OSError
398
+ If copying fails.
399
+ """
400
+ dist_path = Path(__file__).parent / "dist" / "lib" / "list-distributor.js"
401
+ output_path = self.output_dir / "js" / "list_distributor.js"
402
+
403
+ if not dist_path.exists():
404
+ raise FileNotFoundError(
405
+ f"list-distributor.js not found at {dist_path}. "
406
+ f"Ensure TypeScript is compiled. "
407
+ f"Run 'npm run build' in the jspsych directory."
408
+ )
409
+
410
+ try:
411
+ output_path.write_text(dist_path.read_text())
412
+ except OSError as e:
413
+ raise OSError(
414
+ f"Failed to copy list_distributor.js to {output_path}: {e}. "
415
+ f"Check write permissions."
416
+ ) from e
417
+
418
+ def _create_directory_structure(self) -> None:
419
+ """Create output directory structure.
420
+
421
+ Creates:
422
+ - output_dir/
423
+ - output_dir/css/
424
+ - output_dir/js/
425
+ - output_dir/data/
426
+ """
427
+ self.output_dir.mkdir(parents=True, exist_ok=True)
428
+ (self.output_dir / "css").mkdir(exist_ok=True)
429
+ (self.output_dir / "js").mkdir(exist_ok=True)
430
+ (self.output_dir / "data").mkdir(exist_ok=True)
431
+
432
+ def _generate_html(self) -> None:
433
+ """Generate index.html file."""
434
+ template = self.jinja_env.get_template("index.html")
435
+
436
+ html_content = template.render(
437
+ title=self.config.title,
438
+ ui_theme=self.config.ui_theme,
439
+ use_jatos=self.config.use_jatos,
440
+ slopit_enabled=self.config.slopit.enabled,
441
+ )
442
+
443
+ output_file = self.output_dir / "index.html"
444
+ output_file.write_text(html_content)
445
+
446
+ def _generate_css(self) -> None:
447
+ """Generate experiment.css file by copying template."""
448
+ template_file = Path(__file__).parent / "templates" / "experiment.css"
449
+ output_file = self.output_dir / "css" / "experiment.css"
450
+
451
+ # Copy CSS template directly (no rendering needed)
452
+ output_file.write_text(template_file.read_text())
453
+
454
+ def _generate_experiment_script(self) -> None:
455
+ """Generate experiment.js file."""
456
+ template = self.jinja_env.get_template("experiment.js.template")
457
+
458
+ # Auto-generate Prolific redirect URL if completion code is provided
459
+ on_finish_url = self.config.on_finish_url
460
+ if self.config.prolific_completion_code:
461
+ on_finish_url = (
462
+ f"https://app.prolific.co/submissions/complete?"
463
+ f"cc={self.config.prolific_completion_code}"
464
+ )
465
+
466
+ # Prepare slopit config for template
467
+ slopit_config = None
468
+ if self.config.slopit.enabled:
469
+ slopit_config = {
470
+ "keystroke": self.config.slopit.keystroke.model_dump(),
471
+ "focus": self.config.slopit.focus.model_dump(),
472
+ "paste": self.config.slopit.paste.model_dump(),
473
+ "target_selectors": self.config.slopit.target_selectors,
474
+ }
475
+
476
+ # Prepare demographics config for template
477
+ demographics_enabled = False
478
+ demographics_title = "Participant Information"
479
+ demographics_fields: list[dict[str, JsonValue]] = []
480
+ demographics_submit_text = "Continue"
481
+
482
+ if self.config.demographics is not None and self.config.demographics.enabled:
483
+ demographics_enabled = True
484
+ demographics_title = self.config.demographics.title
485
+ demographics_submit_text = self.config.demographics.submit_button_text
486
+ for field in self.config.demographics.fields:
487
+ field_data: dict[str, JsonValue] = {
488
+ "name": field.name,
489
+ "label": field.label,
490
+ "field_type": field.field_type,
491
+ "required": field.required,
492
+ }
493
+ if field.placeholder:
494
+ field_data["placeholder"] = field.placeholder
495
+ if field.options:
496
+ field_data["options"] = field.options
497
+ if field.range is not None:
498
+ field_data["range_min"] = field.range.min
499
+ field_data["range_max"] = field.range.max
500
+ demographics_fields.append(field_data)
501
+
502
+ # Prepare instructions config for template
503
+ instructions_is_multi_page = isinstance(
504
+ self.config.instructions, InstructionsConfig
505
+ )
506
+ instructions_pages: list[dict[str, str | None]] = []
507
+ instructions_show_page_numbers = True
508
+ instructions_allow_backwards = True
509
+ instructions_button_next = "Next"
510
+ instructions_button_finish = "Begin Experiment"
511
+ simple_instructions: str | None = None
512
+
513
+ if instructions_is_multi_page:
514
+ assert isinstance(self.config.instructions, InstructionsConfig)
515
+ instructions_show_page_numbers = self.config.instructions.show_page_numbers
516
+ instructions_allow_backwards = self.config.instructions.allow_backwards
517
+ instructions_button_next = self.config.instructions.button_label_next
518
+ instructions_button_finish = self.config.instructions.button_label_finish
519
+ for page in self.config.instructions.pages:
520
+ instructions_pages.append(
521
+ {
522
+ "title": page.title,
523
+ "content": page.content,
524
+ }
525
+ )
526
+ else:
527
+ # Simple string instructions
528
+ simple_instructions = (
529
+ self.config.instructions
530
+ if isinstance(self.config.instructions, str)
531
+ else None
532
+ )
533
+
534
+ js_content = template.render(
535
+ title=self.config.title,
536
+ description=self.config.description,
537
+ instructions=simple_instructions,
538
+ show_progress_bar=self.config.show_progress_bar,
539
+ use_jatos=self.config.use_jatos,
540
+ on_finish_url=on_finish_url,
541
+ slopit_enabled=self.config.slopit.enabled,
542
+ slopit_config=slopit_config,
543
+ # Demographics variables
544
+ demographics_enabled=demographics_enabled,
545
+ demographics_title=demographics_title,
546
+ demographics_fields=demographics_fields,
547
+ demographics_submit_text=demographics_submit_text,
548
+ # Instructions variables
549
+ instructions_is_multi_page=instructions_is_multi_page,
550
+ instructions_pages=instructions_pages,
551
+ instructions_show_page_numbers=instructions_show_page_numbers,
552
+ instructions_allow_backwards=instructions_allow_backwards,
553
+ instructions_button_next=instructions_button_next,
554
+ instructions_button_finish=instructions_button_finish,
555
+ )
556
+
557
+ output_file = self.output_dir / "js" / "experiment.js"
558
+ output_file.write_text(js_content)
559
+
560
+ def _generate_config_file(self) -> None:
561
+ """Generate config.json file with experiment configuration."""
562
+ output_file = self.output_dir / "data" / "config.json"
563
+ json_str = self.config.model_dump_json(indent=2)
564
+ output_file.write_text(json_str)
565
+
566
+ def _copy_slopit_bundle(self) -> None:
567
+ """Copy slopit bundle to js/ directory.
568
+
569
+ Copies the pre-built slopit bundle from the bead deployment dist
570
+ directory to the experiment output directory.
571
+
572
+ Raises
573
+ ------
574
+ FileNotFoundError
575
+ If slopit bundle is not found.
576
+ OSError
577
+ If copying fails.
578
+ """
579
+ # Look for slopit bundle in dist directory
580
+ dist_dir = Path(__file__).parent / "dist"
581
+ bundle_path = dist_dir / "slopit-bundle.js"
582
+
583
+ if not bundle_path.exists():
584
+ raise FileNotFoundError(
585
+ f"Slopit bundle not found at {bundle_path}. "
586
+ f"Ensure the slopit packages are built. "
587
+ f"Run 'npm run build' in the jspsych directory, or install "
588
+ f"bead with: pip install bead[behavioral-analysis]"
589
+ )
590
+
591
+ output_path = self.output_dir / "js" / "slopit-bundle.js"
592
+ try:
593
+ output_path.write_text(bundle_path.read_text())
594
+ except OSError as e:
595
+ raise OSError(
596
+ f"Failed to copy slopit bundle to {output_path}: {e}. "
597
+ f"Check write permissions."
598
+ ) from e
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@bead/jspsych-deployment",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript plugins and utilities for bead jsPsych experiment deployment",
5
+ "private": true,
6
+ "type": "module",
7
+ "exports": {
8
+ "./plugins/*": {
9
+ "import": "./dist/plugins/*.js",
10
+ "types": "./dist/plugins/*.d.ts"
11
+ },
12
+ "./lib/*": {
13
+ "import": "./dist/lib/*.js",
14
+ "types": "./dist/lib/*.d.ts"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "build:watch": "tsup --watch",
20
+ "typecheck": "tsc --noEmit",
21
+ "lint": "biome lint src",
22
+ "lint:fix": "biome lint --write src",
23
+ "format": "biome format --check src",
24
+ "format:fix": "biome format --write src",
25
+ "check": "biome check src",
26
+ "check:fix": "biome check --write src",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest",
29
+ "clean": "rm -rf dist"
30
+ },
31
+ "dependencies": {
32
+ "@slopit/adapter-jspsych": "^0.1.0",
33
+ "@slopit/behavioral": "^0.1.0",
34
+ "@slopit/core": "^0.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@biomejs/biome": "^1.9.0",
38
+ "@types/node": "^22.0.0",
39
+ "jsdom": "^25.0.0",
40
+ "jspsych": "^8.0.0",
41
+ "tsup": "^8.0.0",
42
+ "typescript": "^5.7.0",
43
+ "vitest": "^2.0.0"
44
+ },
45
+ "peerDependencies": {
46
+ "jspsych": "^8.0.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=22.0.0"
50
+ }
51
+ }