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,723 @@
1
+ """Trial generators for jsPsych experiments.
2
+
3
+ This module provides functions to generate jsPsych trial objects from
4
+ Item models. It supports various trial types including rating scales,
5
+ forced choice, and binary choice trials.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from bead.data.base import JsonValue
11
+ from bead.deployment.jspsych.config import (
12
+ ChoiceConfig,
13
+ DemographicsConfig,
14
+ DemographicsFieldConfig,
15
+ ExperimentConfig,
16
+ InstructionsConfig,
17
+ RatingScaleConfig,
18
+ )
19
+ from bead.items.item import Item
20
+ from bead.items.item_template import ItemTemplate
21
+
22
+
23
+ def _serialize_item_metadata(
24
+ item: Item, template: ItemTemplate
25
+ ) -> dict[str, JsonValue]:
26
+ """Serialize complete item and template metadata for trial data.
27
+
28
+ Parameters
29
+ ----------
30
+ item : Item
31
+ The item to serialize metadata from.
32
+ template : ItemTemplate
33
+ The item template to serialize metadata from.
34
+
35
+ Returns
36
+ -------
37
+ dict[str, JsonValue]
38
+ Metadata dictionary containing all item and template fields.
39
+ """
40
+ return {
41
+ # Item identification
42
+ "item_id": str(item.id),
43
+ "item_created": item.created_at.isoformat(),
44
+ "item_modified": item.modified_at.isoformat(),
45
+ # Item template reference
46
+ "item_template_id": str(item.item_template_id),
47
+ # Filled template references
48
+ "filled_template_refs": [str(ref) for ref in item.filled_template_refs],
49
+ # Options (for forced_choice/multi_select)
50
+ "options": list(item.options),
51
+ # Rendered elements
52
+ "rendered_elements": dict(item.rendered_elements),
53
+ # Unfilled slots (for cloze tasks)
54
+ "unfilled_slots": [
55
+ {
56
+ "slot_name": slot.slot_name,
57
+ "position": slot.position,
58
+ "constraint_ids": [str(cid) for cid in slot.constraint_ids],
59
+ }
60
+ for slot in item.unfilled_slots
61
+ ],
62
+ # Model outputs
63
+ "model_outputs": [
64
+ {
65
+ "model_name": output.model_name,
66
+ "model_version": output.model_version,
67
+ "operation": output.operation,
68
+ "inputs": output.inputs,
69
+ "output": output.output,
70
+ "cache_key": output.cache_key,
71
+ "computation_metadata": output.computation_metadata,
72
+ }
73
+ for output in item.model_outputs
74
+ ],
75
+ # Constraint satisfaction
76
+ "constraint_satisfaction": {
77
+ str(k): v for k, v in item.constraint_satisfaction.items()
78
+ },
79
+ # Item-specific metadata
80
+ "item_metadata": dict(item.item_metadata),
81
+ # Template information
82
+ "template_name": template.name,
83
+ "template_description": template.description,
84
+ "judgment_type": template.judgment_type,
85
+ "task_type": template.task_type,
86
+ # Template elements
87
+ "template_elements": [
88
+ {
89
+ "element_type": elem.element_type,
90
+ "element_name": elem.element_name,
91
+ "content": elem.content,
92
+ "filled_template_ref_id": (
93
+ str(elem.filled_template_ref_id)
94
+ if elem.filled_template_ref_id
95
+ else None
96
+ ),
97
+ "element_metadata": elem.element_metadata,
98
+ "order": elem.order,
99
+ }
100
+ for elem in template.elements
101
+ ],
102
+ # Template constraints
103
+ "template_constraints": [str(c) for c in template.constraints],
104
+ # Task specification
105
+ "task_spec": {
106
+ "prompt": template.task_spec.prompt,
107
+ "scale_bounds": template.task_spec.scale_bounds,
108
+ "scale_labels": template.task_spec.scale_labels,
109
+ "options": template.task_spec.options,
110
+ "min_selections": template.task_spec.min_selections,
111
+ "max_selections": template.task_spec.max_selections,
112
+ "text_validation_pattern": template.task_spec.text_validation_pattern,
113
+ "max_length": template.task_spec.max_length,
114
+ },
115
+ # Presentation specification
116
+ "presentation_spec": {
117
+ "mode": template.presentation_spec.mode,
118
+ "chunking": (
119
+ {
120
+ "unit": template.presentation_spec.chunking.unit,
121
+ "parse_type": (template.presentation_spec.chunking.parse_type),
122
+ "constituent_labels": (
123
+ template.presentation_spec.chunking.constituent_labels
124
+ ),
125
+ "parser": template.presentation_spec.chunking.parser,
126
+ "parse_language": (
127
+ template.presentation_spec.chunking.parse_language
128
+ ),
129
+ "custom_boundaries": (
130
+ template.presentation_spec.chunking.custom_boundaries
131
+ ),
132
+ }
133
+ if template.presentation_spec.chunking
134
+ else None
135
+ ),
136
+ "timing": (
137
+ {
138
+ "duration_ms": template.presentation_spec.timing.duration_ms,
139
+ "isi_ms": template.presentation_spec.timing.isi_ms,
140
+ "timeout_ms": template.presentation_spec.timing.timeout_ms,
141
+ "mask_char": template.presentation_spec.timing.mask_char,
142
+ "cumulative": template.presentation_spec.timing.cumulative,
143
+ }
144
+ if template.presentation_spec.timing
145
+ else None
146
+ ),
147
+ "display_format": template.presentation_spec.display_format,
148
+ },
149
+ # Presentation order
150
+ "presentation_order": template.presentation_order,
151
+ # Template metadata
152
+ "template_metadata": dict(template.template_metadata),
153
+ }
154
+
155
+
156
+ def create_trial(
157
+ item: Item,
158
+ template: ItemTemplate,
159
+ experiment_config: ExperimentConfig,
160
+ trial_number: int,
161
+ rating_config: RatingScaleConfig | None = None,
162
+ choice_config: ChoiceConfig | None = None,
163
+ ) -> dict[str, JsonValue]:
164
+ """Create a jsPsych trial object from an Item.
165
+
166
+ Parameters
167
+ ----------
168
+ item : Item
169
+ The item to create a trial from.
170
+ template : ItemTemplate
171
+ The item template for this item.
172
+ experiment_config : ExperimentConfig
173
+ The experiment configuration.
174
+ trial_number : int
175
+ The trial number (for tracking).
176
+ rating_config : RatingScaleConfig | None
177
+ Configuration for rating scale trials (required for rating types).
178
+ choice_config : ChoiceConfig | None
179
+ Configuration for choice trials (required for choice types).
180
+
181
+ Returns
182
+ -------
183
+ dict[str, JsonValue]
184
+ A jsPsych trial object with item and template metadata.
185
+
186
+ Raises
187
+ ------
188
+ ValueError
189
+ If required configuration is missing for the experiment type.
190
+
191
+ Examples
192
+ --------
193
+ >>> from uuid import UUID
194
+ >>> from bead.items.item_template import TaskSpec, PresentationSpec
195
+ >>> item = Item(
196
+ ... item_template_id=UUID("12345678-1234-5678-1234-567812345678"),
197
+ ... rendered_elements={"sentence": "The cat broke the vase"}
198
+ ... )
199
+ >>> template = ItemTemplate(
200
+ ... name="test",
201
+ ... judgment_type="acceptability",
202
+ ... task_type="ordinal_scale",
203
+ ... task_spec=TaskSpec(prompt="Rate this"),
204
+ ... presentation_spec=PresentationSpec(mode="static")
205
+ ... )
206
+ >>> config = ExperimentConfig(
207
+ ... experiment_type="likert_rating",
208
+ ... title="Test",
209
+ ... description="Test",
210
+ ... instructions="Test"
211
+ ... )
212
+ >>> rating_config = RatingScaleConfig()
213
+ >>> trial = create_trial(item, template, config, 0, rating_config=rating_config)
214
+ >>> trial["type"]
215
+ 'html-slider-response'
216
+ """
217
+ if experiment_config.experiment_type == "likert_rating":
218
+ if rating_config is None:
219
+ raise ValueError("rating_config required for likert_rating experiments")
220
+ return _create_likert_trial(item, template, rating_config, trial_number)
221
+ elif experiment_config.experiment_type == "slider_rating":
222
+ if rating_config is None:
223
+ raise ValueError("rating_config required for slider_rating experiments")
224
+ return _create_slider_trial(item, template, rating_config, trial_number)
225
+ elif experiment_config.experiment_type == "binary_choice":
226
+ if choice_config is None:
227
+ raise ValueError("choice_config required for binary_choice experiments")
228
+ return _create_binary_choice_trial(item, template, choice_config, trial_number)
229
+ elif experiment_config.experiment_type == "forced_choice":
230
+ if choice_config is None:
231
+ raise ValueError("choice_config required for forced_choice experiments")
232
+ return _create_forced_choice_trial(item, template, choice_config, trial_number)
233
+ else:
234
+ raise ValueError(
235
+ f"Unknown experiment type: {experiment_config.experiment_type}"
236
+ )
237
+
238
+
239
+ def _create_likert_trial(
240
+ item: Item,
241
+ template: ItemTemplate,
242
+ config: RatingScaleConfig,
243
+ trial_number: int,
244
+ ) -> dict[str, JsonValue]:
245
+ """Create a Likert rating trial.
246
+
247
+ Parameters
248
+ ----------
249
+ item : Item
250
+ The item to create a trial from.
251
+ template : ItemTemplate
252
+ The item template.
253
+ config : RatingScaleConfig
254
+ Rating scale configuration.
255
+ trial_number : int
256
+ The trial number.
257
+
258
+ Returns
259
+ -------
260
+ dict[str, JsonValue]
261
+ A jsPsych html-button-response trial object.
262
+ """
263
+ # Generate stimulus HTML from rendered elements
264
+ stimulus_html = _generate_stimulus_html(item)
265
+
266
+ # Generate button labels for Likert scale
267
+ labels: list[str] = []
268
+ for i in range(config.scale.min, config.scale.max + 1, config.step):
269
+ if config.show_numeric_labels:
270
+ labels.append(str(i))
271
+ else:
272
+ labels.append("")
273
+
274
+ prompt_html = (
275
+ f'<p style="margin-top: 20px;">'
276
+ f'<span style="float: left;">{config.min_label}</span>'
277
+ f'<span style="float: right;">{config.max_label}</span>'
278
+ f"</p>"
279
+ )
280
+
281
+ # Serialize complete metadata
282
+ metadata = _serialize_item_metadata(item, template)
283
+ metadata["trial_number"] = trial_number
284
+ metadata["trial_type"] = "likert_rating"
285
+
286
+ return {
287
+ "type": "html-button-response",
288
+ "stimulus": stimulus_html,
289
+ "choices": labels,
290
+ "prompt": prompt_html,
291
+ "data": metadata,
292
+ "button_html": '<button class="jspsych-btn likert-button">%choice%</button>',
293
+ }
294
+
295
+
296
+ def _create_slider_trial(
297
+ item: Item,
298
+ template: ItemTemplate,
299
+ config: RatingScaleConfig,
300
+ trial_number: int,
301
+ ) -> dict[str, JsonValue]:
302
+ """Create a slider rating trial.
303
+
304
+ Parameters
305
+ ----------
306
+ item : Item
307
+ The item to create a trial from.
308
+ template : ItemTemplate
309
+ The item template.
310
+ config : RatingScaleConfig
311
+ Rating scale configuration.
312
+ trial_number : int
313
+ The trial number.
314
+
315
+ Returns
316
+ -------
317
+ dict[str, JsonValue]
318
+ A jsPsych html-slider-response trial object.
319
+ """
320
+ stimulus_html = _generate_stimulus_html(item)
321
+
322
+ # Serialize complete metadata
323
+ metadata = _serialize_item_metadata(item, template)
324
+ metadata["trial_number"] = trial_number
325
+ metadata["trial_type"] = "slider_rating"
326
+
327
+ return {
328
+ "type": "html-slider-response",
329
+ "stimulus": stimulus_html,
330
+ "labels": [config.min_label, config.max_label],
331
+ "min": config.scale.min,
332
+ "max": config.scale.max,
333
+ "step": config.step,
334
+ "slider_start": (config.scale.min + config.scale.max) // 2,
335
+ "require_movement": config.required,
336
+ "data": metadata,
337
+ }
338
+
339
+
340
+ def _create_binary_choice_trial(
341
+ item: Item,
342
+ template: ItemTemplate,
343
+ config: ChoiceConfig,
344
+ trial_number: int,
345
+ ) -> dict[str, JsonValue]:
346
+ """Create a binary choice trial.
347
+
348
+ Parameters
349
+ ----------
350
+ item : Item
351
+ The item to create a trial from.
352
+ template : ItemTemplate
353
+ The item template.
354
+ config : ChoiceConfig
355
+ Choice configuration.
356
+ trial_number : int
357
+ The trial number.
358
+
359
+ Returns
360
+ -------
361
+ dict[str, JsonValue]
362
+ A jsPsych html-button-response trial object.
363
+ """
364
+ stimulus_html = _generate_stimulus_html(item)
365
+
366
+ # Serialize complete metadata
367
+ metadata = _serialize_item_metadata(item, template)
368
+ metadata["trial_number"] = trial_number
369
+ metadata["trial_type"] = "binary_choice"
370
+
371
+ return {
372
+ "type": "html-button-response",
373
+ "stimulus": stimulus_html,
374
+ "choices": ["Yes", "No"],
375
+ "data": metadata,
376
+ "button_html": config.button_html
377
+ or '<button class="jspsych-btn">%choice%</button>',
378
+ }
379
+
380
+
381
+ def _create_forced_choice_trial(
382
+ item: Item,
383
+ template: ItemTemplate,
384
+ config: ChoiceConfig,
385
+ trial_number: int,
386
+ ) -> dict[str, JsonValue]:
387
+ """Create a forced choice trial.
388
+
389
+ Parameters
390
+ ----------
391
+ item : Item
392
+ The item to create a trial from. Must have at least 2 options in
393
+ the item.options list.
394
+ template : ItemTemplate
395
+ The item template.
396
+ config : ChoiceConfig
397
+ Choice configuration.
398
+ trial_number : int
399
+ The trial number.
400
+
401
+ Returns
402
+ -------
403
+ dict[str, JsonValue]
404
+ A jsPsych html-button-response trial object.
405
+
406
+ Raises
407
+ ------
408
+ ValueError
409
+ If item.options is empty or has fewer than 2 options.
410
+ """
411
+ # For forced choice, use the prompt from the template as the stimulus
412
+ # (not the choices themselves)
413
+ prompt = (
414
+ template.task_spec.prompt
415
+ if template.task_spec
416
+ else "Which option do you choose?"
417
+ )
418
+ stimulus_html = (
419
+ f'<div class="stimulus-container"><p class="prompt">{prompt}</p></div>'
420
+ )
421
+
422
+ # Extract choices from item.options
423
+ if not item.options:
424
+ raise ValueError(
425
+ f"Item {item.id} has no options. "
426
+ f"Forced choice items must have at least 2 options in item.options. "
427
+ f"Use create_forced_choice_item() to create items with options."
428
+ )
429
+ if len(item.options) < 2:
430
+ raise ValueError(
431
+ f"Item {item.id} has only {len(item.options)} option(s). "
432
+ f"Forced choice items require at least 2 options."
433
+ )
434
+ choices = list(item.options)
435
+
436
+ # Serialize complete metadata
437
+ metadata = _serialize_item_metadata(item, template)
438
+ metadata["trial_number"] = trial_number
439
+ metadata["trial_type"] = "forced_choice"
440
+
441
+ return {
442
+ "type": "html-button-response",
443
+ "stimulus": stimulus_html,
444
+ "choices": choices,
445
+ "data": metadata,
446
+ "button_html": config.button_html
447
+ or '<button class="jspsych-btn">%choice%</button>',
448
+ }
449
+
450
+
451
+ def _generate_stimulus_html(item: Item, include_all: bool = True) -> str:
452
+ """Generate HTML for stimulus presentation.
453
+
454
+ Parameters
455
+ ----------
456
+ item : Item
457
+ The item to generate HTML for.
458
+ include_all : bool
459
+ Whether to include all rendered elements (True) or just the first one (False).
460
+
461
+ Returns
462
+ -------
463
+ str
464
+ HTML string for the stimulus.
465
+ """
466
+ if not item.rendered_elements:
467
+ return "<p>No stimulus available</p>"
468
+
469
+ # Get rendered elements in a consistent order
470
+ sorted_keys = sorted(item.rendered_elements.keys())
471
+
472
+ if include_all:
473
+ # Include all rendered elements
474
+ elements = [
475
+ f'<div class="stimulus-element"><p>{item.rendered_elements[k]}</p></div>'
476
+ for k in sorted_keys
477
+ ]
478
+ return '<div class="stimulus-container">' + "".join(elements) + "</div>"
479
+ else:
480
+ # Include only the first element (for forced choice where others are options)
481
+ first_key = sorted_keys[0]
482
+ element_html = item.rendered_elements[first_key]
483
+ return f'<div class="stimulus-container"><p>{element_html}</p></div>'
484
+
485
+
486
+ def create_consent_trial(consent_text: str) -> dict[str, JsonValue]:
487
+ """Create a consent trial.
488
+
489
+ Parameters
490
+ ----------
491
+ consent_text : str
492
+ The consent text to display.
493
+
494
+ Returns
495
+ -------
496
+ dict[str, JsonValue]
497
+ A jsPsych html-button-response trial object.
498
+ """
499
+ stimulus_html = (
500
+ f'<div class="consent"><h2>Consent</h2><div>{consent_text}</div></div>'
501
+ )
502
+
503
+ return {
504
+ "type": "html-button-response",
505
+ "stimulus": stimulus_html,
506
+ "choices": ["I agree", "I do not agree"],
507
+ "data": {
508
+ "trial_type": "consent",
509
+ },
510
+ }
511
+
512
+
513
+ def create_completion_trial(
514
+ completion_message: str = "Thank you for participating!",
515
+ ) -> dict[str, JsonValue]:
516
+ """Create a completion trial.
517
+
518
+ Parameters
519
+ ----------
520
+ completion_message : str
521
+ The completion message to display.
522
+
523
+ Returns
524
+ -------
525
+ dict[str, JsonValue]
526
+ A jsPsych html-keyboard-response trial object.
527
+ """
528
+ stimulus_html = (
529
+ f'<div class="completion"><h2>Complete</h2><p>{completion_message}</p></div>'
530
+ )
531
+
532
+ return {
533
+ "type": "html-keyboard-response",
534
+ "stimulus": stimulus_html,
535
+ "choices": "NO_KEYS",
536
+ "data": {
537
+ "trial_type": "completion",
538
+ },
539
+ }
540
+
541
+
542
+ def _create_survey_question(field: DemographicsFieldConfig) -> dict[str, JsonValue]:
543
+ """Create a jsPsych survey question from a demographics field config.
544
+
545
+ Parameters
546
+ ----------
547
+ field : DemographicsFieldConfig
548
+ The field configuration.
549
+
550
+ Returns
551
+ -------
552
+ dict[str, JsonValue]
553
+ A jsPsych survey question object.
554
+ """
555
+ question: dict[str, JsonValue] = {
556
+ "name": field.name,
557
+ "prompt": field.label,
558
+ "required": field.required,
559
+ }
560
+
561
+ if field.field_type == "text":
562
+ question["type"] = "text"
563
+ if field.placeholder:
564
+ question["placeholder"] = field.placeholder
565
+
566
+ elif field.field_type == "number":
567
+ question["type"] = "text"
568
+ question["input_type"] = "number"
569
+ if field.placeholder:
570
+ question["placeholder"] = field.placeholder
571
+ if field.range is not None:
572
+ question["min"] = field.range.min
573
+ question["max"] = field.range.max
574
+
575
+ elif field.field_type == "dropdown":
576
+ question["type"] = "drop-down"
577
+ if field.options:
578
+ question["options"] = field.options
579
+
580
+ elif field.field_type == "radio":
581
+ question["type"] = "multi-choice"
582
+ if field.options:
583
+ question["options"] = field.options
584
+
585
+ elif field.field_type == "checkbox":
586
+ question["type"] = "multi-select"
587
+ if field.options:
588
+ question["options"] = field.options
589
+
590
+ return question
591
+
592
+
593
+ def create_demographics_trial(config: DemographicsConfig) -> dict[str, JsonValue]:
594
+ """Create a demographics survey trial.
595
+
596
+ Parameters
597
+ ----------
598
+ config : DemographicsConfig
599
+ The demographics form configuration.
600
+
601
+ Returns
602
+ -------
603
+ dict[str, JsonValue]
604
+ A jsPsych survey trial object.
605
+
606
+ Examples
607
+ --------
608
+ >>> from bead.deployment.jspsych.config import (
609
+ ... DemographicsConfig, DemographicsFieldConfig
610
+ ... )
611
+ >>> config = DemographicsConfig(
612
+ ... enabled=True,
613
+ ... title="About You",
614
+ ... fields=[
615
+ ... DemographicsFieldConfig(
616
+ ... name="age",
617
+ ... field_type="number",
618
+ ... label="Your Age",
619
+ ... required=True,
620
+ ... ),
621
+ ... ],
622
+ ... )
623
+ >>> trial = create_demographics_trial(config)
624
+ >>> trial["type"]
625
+ 'survey'
626
+ """
627
+ questions = [_create_survey_question(field) for field in config.fields]
628
+
629
+ return {
630
+ "type": "survey",
631
+ "title": config.title,
632
+ "pages": [questions],
633
+ "button_label_finish": config.submit_button_text,
634
+ "data": {
635
+ "trial_type": "demographics",
636
+ },
637
+ }
638
+
639
+
640
+ def create_instructions_trial(
641
+ instructions: str | InstructionsConfig,
642
+ ) -> dict[str, JsonValue]:
643
+ """Create an instruction trial supporting both simple strings and rich config.
644
+
645
+ Parameters
646
+ ----------
647
+ instructions : str | InstructionsConfig
648
+ Either a simple instruction string (single page, keyboard response)
649
+ or an InstructionsConfig for multi-page instructions.
650
+
651
+ Returns
652
+ -------
653
+ dict[str, JsonValue]
654
+ A jsPsych trial object. For simple strings, returns html-keyboard-response.
655
+ For InstructionsConfig, returns an instructions plugin trial.
656
+
657
+ Examples
658
+ --------
659
+ >>> # Simple string instructions
660
+ >>> trial = create_instructions_trial("Rate each sentence from 1-7.")
661
+ >>> trial["type"]
662
+ 'html-keyboard-response'
663
+
664
+ >>> # Multi-page instructions
665
+ >>> from bead.deployment.jspsych.config import InstructionsConfig, InstructionPage
666
+ >>> config = InstructionsConfig(
667
+ ... pages=[
668
+ ... InstructionPage(title="Welcome", content="<p>Welcome!</p>"),
669
+ ... InstructionPage(title="Task", content="<p>Rate sentences.</p>"),
670
+ ... ],
671
+ ... )
672
+ >>> trial = create_instructions_trial(config)
673
+ >>> trial["type"]
674
+ 'instructions'
675
+ >>> len(trial["pages"])
676
+ 2
677
+ """
678
+ if isinstance(instructions, str):
679
+ # Simple string: use html-keyboard-response (backward compatible)
680
+ stimulus_html = (
681
+ f'<div class="instructions">'
682
+ f"<h2>Instructions</h2>"
683
+ f"<p>{instructions}</p>"
684
+ f"<p><em>Press any key to continue</em></p>"
685
+ f"</div>"
686
+ )
687
+ return {
688
+ "type": "html-keyboard-response",
689
+ "stimulus": stimulus_html,
690
+ "data": {
691
+ "trial_type": "instructions",
692
+ },
693
+ }
694
+
695
+ # InstructionsConfig: use jsPsych instructions plugin
696
+ pages: list[str] = []
697
+ for i, page in enumerate(instructions.pages):
698
+ page_html = '<div class="instructions-page">'
699
+ if page.title:
700
+ page_html += f"<h2>{page.title}</h2>"
701
+ page_html += f"<div>{page.content}</div>"
702
+
703
+ # Add page numbers if enabled
704
+ if instructions.show_page_numbers and len(instructions.pages) > 1:
705
+ page_html += (
706
+ f'<p class="page-number">Page {i + 1} of {len(instructions.pages)}</p>'
707
+ )
708
+
709
+ page_html += "</div>"
710
+ pages.append(page_html)
711
+
712
+ return {
713
+ "type": "instructions",
714
+ "pages": pages,
715
+ "show_clickable_nav": True,
716
+ "allow_backward": instructions.allow_backwards,
717
+ "button_label_next": instructions.button_label_next,
718
+ "button_label_previous": "Previous",
719
+ "button_label_finish": instructions.button_label_finish,
720
+ "data": {
721
+ "trial_type": "instructions",
722
+ },
723
+ }