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,210 @@
1
+ """JATOS exporter for jsPsych experiments.
2
+
3
+ This module provides the JATOSExporter class for creating JATOS study packages (.jzip)
4
+ from generated jsPsych experiments.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import shutil
12
+ import tempfile
13
+ import uuid
14
+ import zipfile
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ from bead.data.base import BeadBaseModel, JsonValue
19
+
20
+
21
+ class JATOSExporter(BeadBaseModel):
22
+ """Exports jsPsych experiments as JATOS study packages (.jzip).
23
+
24
+ A .jzip file is a ZIP archive containing:
25
+ - study.json: JATOS metadata
26
+ - experiment/: All experiment files (HTML, JS, CSS, data)
27
+
28
+ Attributes
29
+ ----------
30
+ study_title : str
31
+ Title of the JATOS study.
32
+ study_description : str
33
+ Description of the study.
34
+
35
+ Examples
36
+ --------
37
+ >>> from pathlib import Path
38
+ >>> exporter = JATOSExporter("Test Study", "A test study")
39
+ >>> # exporter.export(Path("experiment"), Path("study.jzip"))
40
+ """
41
+
42
+ study_title: str
43
+ study_description: str = ""
44
+
45
+ def export(
46
+ self,
47
+ experiment_dir: Path,
48
+ output_path: Path,
49
+ component_title: str = "Main Experiment",
50
+ ) -> None:
51
+ """Create JATOS .jzip file.
52
+
53
+ Parameters
54
+ ----------
55
+ experiment_dir : Path
56
+ Directory containing experiment files (from JsPsychExperimentGenerator).
57
+ Expected structure:
58
+ - index.html
59
+ - css/experiment.css
60
+ - js/experiment.js
61
+ - data/timeline.json
62
+ - data/config.json
63
+ output_path : Path
64
+ Output path for .jzip file.
65
+ component_title : str
66
+ Title for the JATOS component.
67
+
68
+ Raises
69
+ ------
70
+ ValueError
71
+ If experiment_dir does not exist or is missing required files.
72
+ FileNotFoundError
73
+ If required experiment files are not found.
74
+
75
+ Examples
76
+ --------
77
+ >>> exporter = JATOSExporter("Test Study")
78
+ >>> exporter.export(Path("exp"), Path("study.jzip"))
79
+ """
80
+ if not experiment_dir.exists():
81
+ raise ValueError(f"Experiment directory does not exist: {experiment_dir}")
82
+
83
+ if not experiment_dir.is_dir():
84
+ raise ValueError(f"Path is not a directory: {experiment_dir}")
85
+
86
+ # verify required files exist
87
+ required_files = ["index.html"]
88
+ for file in required_files:
89
+ if not (experiment_dir / file).exists():
90
+ raise FileNotFoundError(
91
+ f"Required file not found: {experiment_dir / file}"
92
+ )
93
+
94
+ # create temporary directory for staging
95
+ with tempfile.TemporaryDirectory() as temp_dir:
96
+ temp_path = Path(temp_dir)
97
+
98
+ # create study.json
99
+ study_json = self._create_study_json(component_title)
100
+ study_json_path = temp_path / "study.json"
101
+ study_json_path.write_text(json.dumps(study_json, indent=2))
102
+
103
+ # copy experiment files to temp/experiment/
104
+ experiment_target = temp_path / "experiment"
105
+ shutil.copytree(experiment_dir, experiment_target)
106
+
107
+ # create .jzip file
108
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zipf:
109
+ # add study.json
110
+ zipf.write(study_json_path, "study.json")
111
+
112
+ # add all experiment files
113
+ for file_path in experiment_target.rglob("*"):
114
+ if file_path.is_file():
115
+ # archive name relative to temp_path
116
+ arcname = file_path.relative_to(temp_path)
117
+ zipf.write(file_path, arcname)
118
+
119
+ def _create_study_json(self, component_title: str) -> dict[str, JsonValue]:
120
+ """Create JATOS study.json structure.
121
+
122
+ Parameters
123
+ ----------
124
+ component_title : str
125
+ Title for the JATOS component.
126
+
127
+ Returns
128
+ -------
129
+ dict[str, JsonValue]
130
+ JATOS study metadata dictionary.
131
+
132
+ Notes
133
+ -----
134
+ The study.json follows JATOS v3 schema format.
135
+ """
136
+ # generate UUIDs for study and component
137
+ study_uuid = str(uuid.uuid4())
138
+ component_uuid = str(uuid.uuid4())
139
+
140
+ # sanitize title for directory name
141
+ dir_name = self._sanitize_dirname(self.study_title)
142
+
143
+ return {
144
+ "version": "3",
145
+ "data": {
146
+ "uuid": study_uuid,
147
+ "title": self.study_title,
148
+ "description": self.study_description,
149
+ "dirName": dir_name,
150
+ "comments": f"Generated by bead {datetime.now().isoformat()}",
151
+ "jsonData": None,
152
+ "componentList": [
153
+ {
154
+ "uuid": component_uuid,
155
+ "title": component_title,
156
+ "htmlFilePath": "experiment/index.html",
157
+ "reloadable": True,
158
+ "active": True,
159
+ "comments": "",
160
+ "jsonData": None,
161
+ }
162
+ ],
163
+ "batchList": [],
164
+ "groupStudy": False,
165
+ "linearStudy": False,
166
+ "allowPreview": True,
167
+ },
168
+ }
169
+
170
+ def _sanitize_dirname(self, title: str) -> str:
171
+ """Sanitize study title for use as directory name.
172
+
173
+ Parameters
174
+ ----------
175
+ title : str
176
+ Study title to sanitize.
177
+
178
+ Returns
179
+ -------
180
+ str
181
+ Sanitized directory name.
182
+
183
+ Examples
184
+ --------
185
+ >>> exporter = JATOSExporter("My Study")
186
+ >>> exporter._sanitize_dirname("My Study")
187
+ 'my_study'
188
+ >>> exporter._sanitize_dirname("Study (2024)")
189
+ 'study_2024'
190
+ """
191
+ # convert to lowercase
192
+ dirname = title.lower()
193
+
194
+ # replace spaces with underscores
195
+ dirname = dirname.replace(" ", "_")
196
+
197
+ # remove non-alphanumeric characters (except underscores)
198
+ dirname = re.sub(r"[^a-z0-9_]", "", dirname)
199
+
200
+ # remove consecutive underscores
201
+ dirname = re.sub(r"_+", "_", dirname)
202
+
203
+ # remove leading/trailing underscores
204
+ dirname = dirname.strip("_")
205
+
206
+ # ensure it's not empty
207
+ if not dirname:
208
+ dirname = "study"
209
+
210
+ return dirname
@@ -0,0 +1,9 @@
1
+ """jsPsych 8.x deployment components.
2
+
3
+ Generates jsPsych experiments with batch mode support and server-side list
4
+ distribution via JATOS batch sessions.
5
+ """
6
+
7
+ from bead.deployment.jspsych.randomizer import generate_randomizer_function
8
+
9
+ __all__ = ["generate_randomizer_function"]
@@ -0,0 +1,44 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "include": ["src/**/*.ts"],
10
+ "ignore": ["dist", "node_modules"]
11
+ },
12
+ "organizeImports": {
13
+ "enabled": true
14
+ },
15
+ "linter": {
16
+ "enabled": true,
17
+ "rules": {
18
+ "recommended": true,
19
+ "style": {
20
+ "noNonNullAssertion": "off",
21
+ "useNodejsImportProtocol": "off"
22
+ },
23
+ "suspicious": {
24
+ "noExplicitAny": "warn"
25
+ },
26
+ "complexity": {
27
+ "useLiteralKeys": "off"
28
+ }
29
+ }
30
+ },
31
+ "formatter": {
32
+ "enabled": true,
33
+ "indentStyle": "space",
34
+ "indentWidth": 2,
35
+ "lineWidth": 100
36
+ },
37
+ "javascript": {
38
+ "formatter": {
39
+ "quoteStyle": "double",
40
+ "trailingCommas": "all",
41
+ "semicolons": "always"
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,411 @@
1
+ """Configuration models for jsPsych experiment generation.
2
+
3
+ This module provides Pydantic models for configuring jsPsych experiment
4
+ generation, including experiment types, UI settings, and display options.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from bead.config.deployment import SlopitIntegrationConfig
14
+ from bead.data.range import Range
15
+ from bead.deployment.distribution import ListDistributionStrategy
16
+
17
+ # Type alias for experiment types
18
+ type ExperimentType = Literal[
19
+ "likert_rating",
20
+ "slider_rating",
21
+ "binary_choice",
22
+ "forced_choice",
23
+ ]
24
+
25
+ # Type alias for UI themes
26
+ type UITheme = Literal["light", "dark", "auto"]
27
+
28
+
29
+ # Factory functions for default lists
30
+ def _empty_demographics_fields() -> list[DemographicsFieldConfig]:
31
+ """Return empty demographics field list."""
32
+ return []
33
+
34
+
35
+ def _empty_instruction_pages() -> list[InstructionPage]:
36
+ """Return empty instruction pages list."""
37
+ return []
38
+
39
+
40
+ class DemographicsFieldConfig(BaseModel):
41
+ """Configuration for a single demographics form field.
42
+
43
+ Used to configure fields in a demographics form that appears before
44
+ the experiment instructions. Supports various input types including
45
+ text, number, dropdown, radio buttons, and checkboxes.
46
+
47
+ Attributes
48
+ ----------
49
+ name : str
50
+ Field name (used as key in collected data).
51
+ field_type : Literal["text", "number", "dropdown", "radio", "checkbox"]
52
+ Type of form input.
53
+ label : str
54
+ Display label for the field.
55
+ required : bool
56
+ Whether this field is required (default: False).
57
+ options : list[str] | None
58
+ Options for dropdown/radio fields (default: None).
59
+ range : Range[int] | Range[float] | None
60
+ Numeric range constraint for number fields (default: None).
61
+ placeholder : str | None
62
+ Placeholder text for text/number inputs (default: None).
63
+ help_text : str | None
64
+ Help text displayed below the field (default: None).
65
+
66
+ Examples
67
+ --------
68
+ >>> age_field = DemographicsFieldConfig(
69
+ ... name="age",
70
+ ... field_type="number",
71
+ ... label="Your Age",
72
+ ... required=True,
73
+ ... range=Range[int](min=18, max=100),
74
+ ... )
75
+ >>> education_field = DemographicsFieldConfig(
76
+ ... name="education",
77
+ ... field_type="dropdown",
78
+ ... label="Highest Education Level",
79
+ ... required=True,
80
+ ... options=["High School", "Bachelor's", "Master's", "PhD"],
81
+ ... )
82
+ """
83
+
84
+ model_config = ConfigDict(extra="forbid", frozen=True)
85
+
86
+ name: str
87
+ field_type: Literal["text", "number", "dropdown", "radio", "checkbox"]
88
+ label: str
89
+ required: bool = False
90
+ options: list[str] | None = None
91
+ range: Range[int] | Range[float] | None = None
92
+ placeholder: str | None = None
93
+ help_text: str | None = None
94
+
95
+
96
+ class DemographicsConfig(BaseModel):
97
+ """Configuration for participant demographics form.
98
+
99
+ Defines a demographics form that appears before experiment instructions.
100
+ When enabled, participants must complete this form before proceeding.
101
+
102
+ Attributes
103
+ ----------
104
+ enabled : bool
105
+ Whether to show the demographics form (default: False).
106
+ title : str
107
+ Title displayed at the top of the form (default: "Participant Information").
108
+ fields : list[DemographicsFieldConfig]
109
+ List of fields to include in the form.
110
+ submit_button_text : str
111
+ Text for the submit button (default: "Continue").
112
+
113
+ Examples
114
+ --------
115
+ >>> config = DemographicsConfig(
116
+ ... enabled=True,
117
+ ... title="About You",
118
+ ... fields=[
119
+ ... DemographicsFieldConfig(
120
+ ... name="age",
121
+ ... field_type="number",
122
+ ... label="Age",
123
+ ... required=True,
124
+ ... ),
125
+ ... ],
126
+ ... )
127
+ >>> config.enabled
128
+ True
129
+ """
130
+
131
+ model_config = ConfigDict(extra="forbid")
132
+
133
+ enabled: bool = False
134
+ title: str = "Participant Information"
135
+ fields: list[DemographicsFieldConfig] = Field(
136
+ default_factory=_empty_demographics_fields
137
+ )
138
+ submit_button_text: str = "Continue"
139
+
140
+
141
+ class InstructionPage(BaseModel):
142
+ """A single instruction page for multi-page instructions.
143
+
144
+ Attributes
145
+ ----------
146
+ content : str
147
+ HTML content for this page.
148
+ title : str | None
149
+ Optional title for this page (displayed above content).
150
+
151
+ Examples
152
+ --------
153
+ >>> page = InstructionPage(
154
+ ... title="Welcome",
155
+ ... content="<p>Thank you for participating in this study.</p>",
156
+ ... )
157
+ """
158
+
159
+ model_config = ConfigDict(extra="forbid", frozen=True)
160
+
161
+ content: str
162
+ title: str | None = None
163
+
164
+
165
+ class InstructionsConfig(BaseModel):
166
+ """Configuration for multi-page experiment instructions.
167
+
168
+ Allows creating rich, multi-page instructions with navigation controls.
169
+ Participants can optionally navigate backwards through pages.
170
+
171
+ Attributes
172
+ ----------
173
+ pages : list[InstructionPage]
174
+ List of instruction pages to display.
175
+ show_page_numbers : bool
176
+ Whether to show page numbers (default: True).
177
+ allow_backwards : bool
178
+ Whether to allow navigating to previous pages (default: True).
179
+ button_label_next : str
180
+ Label for the next button (default: "Next").
181
+ button_label_finish : str
182
+ Label for the final button (default: "Begin Experiment").
183
+
184
+ Examples
185
+ --------
186
+ >>> config = InstructionsConfig(
187
+ ... pages=[
188
+ ... InstructionPage(title="Welcome", content="<p>Welcome!</p>"),
189
+ ... InstructionPage(title="Task", content="<p>Your task is...</p>"),
190
+ ... ],
191
+ ... allow_backwards=True,
192
+ ... )
193
+ >>> len(config.pages)
194
+ 2
195
+
196
+ >>> # Create from plain text (single page)
197
+ >>> config = InstructionsConfig.from_text("Please rate each sentence.")
198
+ >>> len(config.pages)
199
+ 1
200
+ """
201
+
202
+ model_config = ConfigDict(extra="forbid")
203
+
204
+ pages: list[InstructionPage] = Field(default_factory=_empty_instruction_pages)
205
+ show_page_numbers: bool = True
206
+ allow_backwards: bool = True
207
+ button_label_next: str = "Next"
208
+ button_label_finish: str = "Begin Experiment"
209
+
210
+ @classmethod
211
+ def from_text(cls, text: str) -> InstructionsConfig:
212
+ """Create single-page instructions from plain text.
213
+
214
+ Provides backward compatibility with simple string instructions.
215
+
216
+ Parameters
217
+ ----------
218
+ text : str
219
+ Plain text or HTML content for a single instruction page.
220
+
221
+ Returns
222
+ -------
223
+ InstructionsConfig
224
+ Instructions config with a single page.
225
+
226
+ Examples
227
+ --------
228
+ >>> config = InstructionsConfig.from_text("Rate each item from 1-7.")
229
+ >>> config.pages[0].content
230
+ 'Rate each item from 1-7.'
231
+ """
232
+ return cls(pages=[InstructionPage(content=text)])
233
+
234
+
235
+ class ExperimentConfig(BaseModel):
236
+ """Configuration for jsPsych experiment generation.
237
+
238
+ Defines all configurable aspects of a jsPsych experiment, including experiment
239
+ type, UI settings, trial presentation options, and list distribution strategy.
240
+
241
+ Attributes
242
+ ----------
243
+ experiment_type : ExperimentType
244
+ Type of experiment (likert_rating, slider_rating, binary_choice, forced_choice)
245
+ title : str
246
+ Experiment title displayed to participants
247
+ description : str
248
+ Brief description of the experiment
249
+ instructions : str | InstructionsConfig
250
+ Instructions shown to participants before the experiment. Can be a simple
251
+ string (single page) or InstructionsConfig for multi-page instructions.
252
+ demographics : DemographicsConfig | None
253
+ Optional demographics form shown before instructions (default: None).
254
+ When provided and enabled, participants must complete this form first.
255
+ distribution_strategy : ListDistributionStrategy
256
+ List distribution strategy for batch mode (required, no default).
257
+ Specifies how participants are assigned to experiment lists using JATOS
258
+ batch sessions. See bead.deployment.distribution for available strategies.
259
+ randomize_trial_order : bool
260
+ Whether to randomize trial order (default: True)
261
+ show_progress_bar : bool
262
+ Whether to show a progress bar during the experiment (default: True)
263
+ ui_theme : UITheme
264
+ UI theme for the experiment (light, dark, auto; default: light)
265
+ on_finish_url : str | None
266
+ URL to redirect to after experiment completion (default: None)
267
+ If prolific_completion_code is set, this will be auto-generated
268
+ allow_backwards : bool
269
+ Whether participants can go back to previous trials (default: False)
270
+ show_click_target : bool
271
+ Whether to show click target for accuracy tracking (default: False)
272
+ minimum_duration_ms : int
273
+ Minimum trial duration in milliseconds (default: 0)
274
+ use_jatos : bool
275
+ Whether to enable JATOS integration (default: True)
276
+ prolific_completion_code : str | None
277
+ Prolific completion code for automatic redirect URL generation (default: None)
278
+ When set, on_finish_url will be auto-generated as:
279
+ https://app.prolific.co/submissions/complete?cc=<code>
280
+ slopit : SlopitIntegrationConfig
281
+ Slopit behavioral capture integration configuration (default: disabled).
282
+ When enabled, captures keystroke dynamics, focus patterns, and paste events
283
+ during experiment trials for AI-assisted response detection.
284
+
285
+ Examples
286
+ --------
287
+ >>> from bead.deployment.distribution import (
288
+ ... ListDistributionStrategy,
289
+ ... DistributionStrategyType
290
+ ... )
291
+ >>> strategy = ListDistributionStrategy(
292
+ ... strategy_type=DistributionStrategyType.BALANCED,
293
+ ... max_participants=100
294
+ ... )
295
+ >>> config = ExperimentConfig(
296
+ ... experiment_type="likert_rating",
297
+ ... title="Sentence Acceptability Study",
298
+ ... description="Rate the acceptability of sentences",
299
+ ... instructions="Please rate each sentence on a scale from 1 to 7.",
300
+ ... distribution_strategy=strategy
301
+ ... )
302
+ >>> config.randomize_trial_order
303
+ True
304
+ >>> config.ui_theme
305
+ 'light'
306
+ """
307
+
308
+ model_config = ConfigDict(
309
+ extra="forbid",
310
+ frozen=False,
311
+ validate_assignment=True,
312
+ )
313
+
314
+ experiment_type: ExperimentType
315
+ title: str
316
+ description: str
317
+ instructions: str | InstructionsConfig
318
+ distribution_strategy: ListDistributionStrategy
319
+ demographics: DemographicsConfig | None = Field(
320
+ default=None,
321
+ description="Demographics form shown before instructions",
322
+ )
323
+ randomize_trial_order: bool = Field(default=True)
324
+ show_progress_bar: bool = Field(default=True)
325
+ ui_theme: UITheme = Field(default="light")
326
+ on_finish_url: str | None = Field(default=None)
327
+ allow_backwards: bool = Field(default=False)
328
+ show_click_target: bool = Field(default=False)
329
+ minimum_duration_ms: int = Field(default=0, ge=0)
330
+ use_jatos: bool = Field(default=True)
331
+ prolific_completion_code: str | None = Field(default=None)
332
+ slopit: SlopitIntegrationConfig = Field(
333
+ default_factory=SlopitIntegrationConfig,
334
+ description="Slopit behavioral capture integration (opt-in, disabled)",
335
+ )
336
+
337
+
338
+ class RatingScaleConfig(BaseModel):
339
+ """Configuration for rating scale trials.
340
+
341
+ Attributes
342
+ ----------
343
+ scale
344
+ Numeric range for the rating scale with min and max values.
345
+ Default is Range(min=1, max=7) for a standard 7-point Likert scale.
346
+ min_label
347
+ Label for the minimum value (default: "Not at all").
348
+ max_label
349
+ Label for the maximum value (default: "Very much").
350
+ step
351
+ Step size between values (default: 1).
352
+ show_numeric_labels
353
+ Whether to show numeric labels on the scale (default: True).
354
+ required
355
+ Whether a response is required (default: True).
356
+
357
+ Examples
358
+ --------
359
+ >>> config = RatingScaleConfig()
360
+ >>> config.scale.min
361
+ 1
362
+ >>> config.scale.max
363
+ 7
364
+ >>> config.scale.contains(4)
365
+ True
366
+
367
+ >>> # Custom 5-point scale
368
+ >>> config = RatingScaleConfig(scale=Range[int](min=1, max=5))
369
+ >>> config.scale.max
370
+ 5
371
+ """
372
+
373
+ model_config = ConfigDict(
374
+ extra="forbid",
375
+ frozen=False,
376
+ validate_assignment=True,
377
+ )
378
+
379
+ scale: Range[int] = Field(
380
+ default_factory=lambda: Range[int](min=1, max=7),
381
+ description="Numeric range for the rating scale",
382
+ )
383
+ min_label: str = Field(default="Not at all")
384
+ max_label: str = Field(default="Very much")
385
+ step: int = Field(default=1, ge=1)
386
+ show_numeric_labels: bool = Field(default=True)
387
+ required: bool = Field(default=True)
388
+
389
+
390
+ class ChoiceConfig(BaseModel):
391
+ """Configuration for choice trials.
392
+
393
+ Attributes
394
+ ----------
395
+ button_html : str | None
396
+ Custom HTML for choice buttons (default: None)
397
+ required : bool
398
+ Whether a response is required (default: True)
399
+ randomize_choice_order : bool
400
+ Whether to randomize the order of choices (default: False)
401
+ """
402
+
403
+ model_config = ConfigDict(
404
+ extra="forbid",
405
+ frozen=False,
406
+ validate_assignment=True,
407
+ )
408
+
409
+ button_html: str | None = Field(default=None)
410
+ required: bool = Field(default=True)
411
+ randomize_choice_order: bool = Field(default=False)