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,491 @@
1
+ """Metadata specification for participant attributes.
2
+
3
+ This module provides FieldSpec and ParticipantMetadataSpec for defining
4
+ configurable metadata fields with validation constraints (allowed values, ranges).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Literal
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
12
+
13
+ from bead.data.range import Range
14
+
15
+ if TYPE_CHECKING:
16
+ from bead.deployment.jspsych.config import DemographicsConfig
17
+
18
+
19
+ def _empty_field_spec_list() -> list[FieldSpec]:
20
+ """Return empty field spec list."""
21
+ return []
22
+
23
+
24
+ class FieldSpec(BaseModel):
25
+ """Specification for a single metadata field.
26
+
27
+ Defines the constraints and display properties for a participant metadata
28
+ field. Used for validation and for generating demographics forms.
29
+
30
+ Attributes
31
+ ----------
32
+ name : str
33
+ Field name (e.g., "age", "education"). Must be valid Python identifier.
34
+ field_type : Literal["int", "float", "str", "bool"]
35
+ Data type for the field.
36
+ required : bool
37
+ Whether this field is required (default: False).
38
+ allowed_values : list[str | int | float | bool] | None
39
+ Exhaustive list of allowed values (for categorical fields).
40
+ If None, any value of the correct type is accepted.
41
+ range : Range[int] | Range[float] | None
42
+ Numeric range constraint (for int/float fields).
43
+ label : str | None
44
+ Display label for forms. If None, uses name with underscores replaced.
45
+ description : str | None
46
+ Help text / description for the field.
47
+
48
+ Examples
49
+ --------
50
+ >>> age_spec = FieldSpec(
51
+ ... name="age",
52
+ ... field_type="int",
53
+ ... required=True,
54
+ ... range=Range[int](min=18, max=100),
55
+ ... label="Age",
56
+ ... description="Your age in years"
57
+ ... )
58
+ >>> education_spec = FieldSpec(
59
+ ... name="education",
60
+ ... field_type="str",
61
+ ... required=True,
62
+ ... allowed_values=["high_school", "bachelors", "masters", "phd"],
63
+ ... label="Highest Education Level"
64
+ ... )
65
+ """
66
+
67
+ model_config = ConfigDict(extra="forbid", frozen=True)
68
+
69
+ name: str
70
+ field_type: Literal["int", "float", "str", "bool"]
71
+ required: bool = False
72
+ allowed_values: list[str | int | float | bool] | None = None
73
+ range: Range[int] | Range[float] | None = None
74
+ label: str | None = None
75
+ description: str | None = None
76
+
77
+ @field_validator("name")
78
+ @classmethod
79
+ def validate_name(cls, v: str) -> str:
80
+ """Validate field name is non-empty and valid identifier.
81
+
82
+ Parameters
83
+ ----------
84
+ v : str
85
+ Field name to validate.
86
+
87
+ Returns
88
+ -------
89
+ str
90
+ Validated field name.
91
+
92
+ Raises
93
+ ------
94
+ ValueError
95
+ If field name is empty or not a valid Python identifier.
96
+ """
97
+ if not v or not v.strip():
98
+ raise ValueError("Field name cannot be empty")
99
+ v = v.strip()
100
+ if not v.isidentifier():
101
+ raise ValueError(f"Field name must be valid Python identifier: {v}")
102
+ return v
103
+
104
+ @model_validator(mode="after")
105
+ def validate_constraints(self) -> FieldSpec:
106
+ """Validate that constraints are consistent with field_type.
107
+
108
+ Returns
109
+ -------
110
+ FieldSpec
111
+ The validated FieldSpec instance.
112
+
113
+ Raises
114
+ ------
115
+ ValueError
116
+ If constraints are inconsistent with field_type.
117
+ """
118
+ # Range constraints only valid for numeric types
119
+ if self.range is not None:
120
+ if self.field_type not in ("int", "float"):
121
+ raise ValueError(
122
+ f"range constraint only valid for numeric types, "
123
+ f"not {self.field_type}"
124
+ )
125
+
126
+ # Validate allowed_values types match field_type
127
+ if self.allowed_values is not None:
128
+ expected_type: type | tuple[type, ...]
129
+ if self.field_type == "int":
130
+ expected_type = int
131
+ elif self.field_type == "float":
132
+ expected_type = (int, float)
133
+ elif self.field_type == "str":
134
+ expected_type = str
135
+ else: # bool
136
+ expected_type = bool
137
+
138
+ for val in self.allowed_values:
139
+ if not isinstance(val, expected_type):
140
+ raise ValueError(
141
+ f"allowed_values item {val!r} does not match "
142
+ f"field_type {self.field_type}"
143
+ )
144
+
145
+ return self
146
+
147
+ def validate_value(self, value: str | int | float | bool | None) -> bool:
148
+ """Check if a value satisfies this field's constraints.
149
+
150
+ Parameters
151
+ ----------
152
+ value : str | int | float | bool | None
153
+ Value to validate.
154
+
155
+ Returns
156
+ -------
157
+ bool
158
+ True if value is valid, False otherwise.
159
+
160
+ Examples
161
+ --------
162
+ >>> spec = FieldSpec(
163
+ ... name="age",
164
+ ... field_type="int",
165
+ ... range=Range[int](min=18, max=100)
166
+ ... )
167
+ >>> spec.validate_value(25)
168
+ True
169
+ >>> spec.validate_value(10)
170
+ False
171
+ """
172
+ if value is None:
173
+ return not self.required
174
+
175
+ # Type check
176
+ expected_type: type | tuple[type, ...]
177
+ if self.field_type == "int":
178
+ expected_type = int
179
+ elif self.field_type == "float":
180
+ expected_type = (int, float)
181
+ elif self.field_type == "str":
182
+ expected_type = str
183
+ else: # bool
184
+ expected_type = bool
185
+
186
+ if not isinstance(value, expected_type):
187
+ return False
188
+
189
+ # Allowed values check
190
+ if self.allowed_values is not None and value not in self.allowed_values:
191
+ return False
192
+
193
+ # Range check
194
+ if self.range is not None and isinstance(value, int | float):
195
+ if not self.range.contains(value): # type: ignore[arg-type]
196
+ return False
197
+
198
+ return True
199
+
200
+ def get_display_label(self) -> str:
201
+ """Get display label for forms.
202
+
203
+ Returns
204
+ -------
205
+ str
206
+ The label if set, otherwise name with underscores replaced by spaces
207
+ and title-cased.
208
+
209
+ Examples
210
+ --------
211
+ >>> spec = FieldSpec(name="native_speaker", field_type="bool")
212
+ >>> spec.get_display_label()
213
+ 'Native Speaker'
214
+ >>> spec = FieldSpec(name="age", field_type="int", label="Your Age")
215
+ >>> spec.get_display_label()
216
+ 'Your Age'
217
+ """
218
+ if self.label:
219
+ return self.label
220
+ return self.name.replace("_", " ").title()
221
+
222
+
223
+ class ParticipantMetadataSpec(BaseModel):
224
+ """Specification for participant metadata schema.
225
+
226
+ Defines the allowed fields and their constraints for participant
227
+ metadata. Used to validate participant data on ingestion and to
228
+ generate demographics forms for experiments.
229
+
230
+ Attributes
231
+ ----------
232
+ name : str
233
+ Name of this specification (e.g., "prolific_demographics").
234
+ version : str
235
+ Version string for this spec.
236
+ fields : list[FieldSpec]
237
+ List of field specifications.
238
+
239
+ Examples
240
+ --------
241
+ >>> spec = ParticipantMetadataSpec(
242
+ ... name="standard_demographics",
243
+ ... version="1.0.0",
244
+ ... fields=[
245
+ ... FieldSpec(
246
+ ... name="age",
247
+ ... field_type="int",
248
+ ... range=Range[int](min=18, max=100)
249
+ ... ),
250
+ ... FieldSpec(
251
+ ... name="education",
252
+ ... field_type="str",
253
+ ... allowed_values=["high_school", "bachelors", "masters", "phd"]
254
+ ... ),
255
+ ... FieldSpec(name="native_speaker", field_type="bool", required=True),
256
+ ... ]
257
+ ... )
258
+ >>> spec.get_field("age").range.min
259
+ 18
260
+ """
261
+
262
+ model_config = ConfigDict(extra="forbid")
263
+
264
+ name: str
265
+ version: str = "1.0.0"
266
+ fields: list[FieldSpec] = Field(default_factory=_empty_field_spec_list)
267
+
268
+ @field_validator("name")
269
+ @classmethod
270
+ def validate_name(cls, v: str) -> str:
271
+ """Validate spec name is non-empty.
272
+
273
+ Parameters
274
+ ----------
275
+ v : str
276
+ Spec name to validate.
277
+
278
+ Returns
279
+ -------
280
+ str
281
+ Validated spec name.
282
+
283
+ Raises
284
+ ------
285
+ ValueError
286
+ If name is empty.
287
+ """
288
+ if not v or not v.strip():
289
+ raise ValueError("Spec name cannot be empty")
290
+ return v.strip()
291
+
292
+ @field_validator("fields")
293
+ @classmethod
294
+ def validate_unique_field_names(cls, v: list[FieldSpec]) -> list[FieldSpec]:
295
+ """Validate all field names are unique.
296
+
297
+ Parameters
298
+ ----------
299
+ v : list[FieldSpec]
300
+ List of field specs to validate.
301
+
302
+ Returns
303
+ -------
304
+ list[FieldSpec]
305
+ Validated list of field specs.
306
+
307
+ Raises
308
+ ------
309
+ ValueError
310
+ If duplicate field names found.
311
+ """
312
+ names = [f.name for f in v]
313
+ if len(names) != len(set(names)):
314
+ duplicates = [n for n in names if names.count(n) > 1]
315
+ raise ValueError(f"Duplicate field names: {set(duplicates)}")
316
+ return v
317
+
318
+ def get_field(self, name: str) -> FieldSpec | None:
319
+ """Get a field specification by name.
320
+
321
+ Parameters
322
+ ----------
323
+ name : str
324
+ Field name to look up.
325
+
326
+ Returns
327
+ -------
328
+ FieldSpec | None
329
+ The field spec if found, None otherwise.
330
+
331
+ Examples
332
+ --------
333
+ >>> spec = ParticipantMetadataSpec(
334
+ ... name="test",
335
+ ... fields=[FieldSpec(name="age", field_type="int")]
336
+ ... )
337
+ >>> spec.get_field("age").field_type
338
+ 'int'
339
+ >>> spec.get_field("unknown") is None
340
+ True
341
+ """
342
+ for field in self.fields:
343
+ if field.name == name:
344
+ return field
345
+ return None
346
+
347
+ def get_required_fields(self) -> list[FieldSpec]:
348
+ """Get all required field specifications.
349
+
350
+ Returns
351
+ -------
352
+ list[FieldSpec]
353
+ List of required fields.
354
+
355
+ Examples
356
+ --------
357
+ >>> spec = ParticipantMetadataSpec(
358
+ ... name="test",
359
+ ... fields=[
360
+ ... FieldSpec(name="age", field_type="int", required=True),
361
+ ... FieldSpec(name="nickname", field_type="str", required=False),
362
+ ... ]
363
+ ... )
364
+ >>> [f.name for f in spec.get_required_fields()]
365
+ ['age']
366
+ """
367
+ return [f for f in self.fields if f.required]
368
+
369
+ def validate_metadata(
370
+ self, metadata: dict[str, str | int | float | bool | None]
371
+ ) -> tuple[bool, list[str]]:
372
+ """Validate metadata against this specification.
373
+
374
+ Parameters
375
+ ----------
376
+ metadata : dict[str, str | int | float | bool | None]
377
+ Metadata dictionary to validate.
378
+
379
+ Returns
380
+ -------
381
+ tuple[bool, list[str]]
382
+ (is_valid, list of error messages). Empty list if valid.
383
+
384
+ Examples
385
+ --------
386
+ >>> spec = ParticipantMetadataSpec(
387
+ ... name="test",
388
+ ... fields=[
389
+ ... FieldSpec(name="age", field_type="int", required=True),
390
+ ... ]
391
+ ... )
392
+ >>> spec.validate_metadata({"age": 25})
393
+ (True, [])
394
+ >>> spec.validate_metadata({})
395
+ (False, ['Missing required field: age'])
396
+ """
397
+ errors: list[str] = []
398
+
399
+ # Check required fields
400
+ for field in self.get_required_fields():
401
+ if field.name not in metadata or metadata[field.name] is None:
402
+ errors.append(f"Missing required field: {field.name}")
403
+
404
+ # Validate each provided field
405
+ for key, value in metadata.items():
406
+ field_spec = self.get_field(key)
407
+ if field_spec is None:
408
+ # Allow arbitrary fields not in spec (for flexibility)
409
+ continue
410
+ if not field_spec.validate_value(value):
411
+ range_str = ""
412
+ if field_spec.range is not None:
413
+ range_str = (
414
+ f", range=[{field_spec.range.min}, {field_spec.range.max}]"
415
+ )
416
+ allowed_str = ""
417
+ if field_spec.allowed_values is not None:
418
+ allowed_str = f", allowed={field_spec.allowed_values}"
419
+ errors.append(
420
+ f"Invalid value for {key}: {value!r} "
421
+ f"(expected {field_spec.field_type}{range_str}{allowed_str})"
422
+ )
423
+
424
+ return len(errors) == 0, errors
425
+
426
+ def to_demographics_config(self) -> DemographicsConfig:
427
+ """Convert this spec to a DemographicsConfig for deployment.
428
+
429
+ Creates a demographics form configuration that can be used in
430
+ experiment deployment to collect participant data.
431
+
432
+ Returns
433
+ -------
434
+ DemographicsConfig
435
+ Demographics configuration for jsPsych deployment.
436
+
437
+ Examples
438
+ --------
439
+ >>> spec = ParticipantMetadataSpec(
440
+ ... name="test",
441
+ ... fields=[
442
+ ... FieldSpec(name="age", field_type="int", required=True),
443
+ ... ]
444
+ ... )
445
+ >>> config = spec.to_demographics_config() # doctest: +SKIP
446
+ >>> config.enabled
447
+ True
448
+ """
449
+ from bead.deployment.jspsych.config import ( # noqa: PLC0415
450
+ DemographicsConfig,
451
+ DemographicsFieldConfig,
452
+ )
453
+
454
+ fields: list[DemographicsFieldConfig] = []
455
+ for field in self.fields:
456
+ # Map field_type to form field_type
457
+ form_field_type: Literal["text", "number", "dropdown", "radio", "checkbox"]
458
+ if field.field_type == "int":
459
+ form_field_type = "number"
460
+ elif field.field_type == "float":
461
+ form_field_type = "number"
462
+ elif field.field_type == "bool":
463
+ form_field_type = "checkbox"
464
+ elif field.allowed_values is not None:
465
+ # Categorical string with options
466
+ form_field_type = "dropdown"
467
+ else:
468
+ form_field_type = "text"
469
+
470
+ # Convert allowed_values to string options for dropdown
471
+ options: list[str] | None = None
472
+ if field.allowed_values is not None:
473
+ options = [str(v) for v in field.allowed_values]
474
+
475
+ fields.append(
476
+ DemographicsFieldConfig(
477
+ name=field.name,
478
+ field_type=form_field_type,
479
+ label=field.get_display_label(),
480
+ required=field.required,
481
+ options=options,
482
+ range=field.range,
483
+ help_text=field.description,
484
+ )
485
+ )
486
+
487
+ return DemographicsConfig(
488
+ enabled=True,
489
+ title="Participant Information",
490
+ fields=fields,
491
+ )