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
+ """Validation utilities for constructed items.
2
+
3
+ This module provides validation functions to ensure constructed items
4
+ meet all requirements and contain complete, valid data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from bead.items.item import Item, ModelOutput
10
+ from bead.items.item_template import ItemTemplate, TaskType
11
+
12
+
13
+ def validate_item(item: Item, item_template: ItemTemplate) -> list[str]:
14
+ """Validate a constructed item against its template.
15
+
16
+ Check that the item has all required fields, references valid templates,
17
+ has consistent constraint satisfaction, and contains valid model outputs.
18
+
19
+ Parameters
20
+ ----------
21
+ item : Item
22
+ Item to validate.
23
+ item_template : ItemTemplate
24
+ Template the item was constructed from.
25
+
26
+ Returns
27
+ -------
28
+ list[str]
29
+ List of validation error messages. Empty list if valid.
30
+
31
+ Examples
32
+ --------
33
+ >>> errors = validate_item(item, template)
34
+ >>> if errors:
35
+ ... print(f"Item is invalid: {errors}")
36
+ >>> else:
37
+ ... print("Item is valid")
38
+ """
39
+ errors: list[str] = []
40
+
41
+ # Check item_template_id matches
42
+ if item.item_template_id != item_template.id:
43
+ errors.append(
44
+ f"Item template ID mismatch: {item.item_template_id} != {item_template.id}"
45
+ )
46
+
47
+ # Check all elements are rendered
48
+ expected_elements = {elem.element_name for elem in item_template.elements}
49
+ actual_elements = set(item.rendered_elements.keys())
50
+
51
+ missing = expected_elements - actual_elements
52
+ if missing:
53
+ errors.append(f"Missing rendered elements: {missing}")
54
+
55
+ extra = actual_elements - expected_elements
56
+ if extra:
57
+ errors.append(f"Extra rendered elements: {extra}")
58
+
59
+ # Check all constraints are evaluated
60
+ expected_constraints = set(item_template.constraints)
61
+ actual_constraints = set(item.constraint_satisfaction.keys())
62
+
63
+ missing_constraints = expected_constraints - actual_constraints
64
+ if missing_constraints:
65
+ errors.append(f"Missing constraint evaluations: {missing_constraints}")
66
+
67
+ # Check model outputs are valid
68
+ for output in item.model_outputs:
69
+ output_errors = validate_model_output(output)
70
+ errors.extend(output_errors)
71
+
72
+ return errors
73
+
74
+
75
+ def validate_model_output(output: ModelOutput) -> list[str]:
76
+ """Validate a model output.
77
+
78
+ Check that the model output has all required fields and valid values.
79
+
80
+ Parameters
81
+ ----------
82
+ output : ModelOutput
83
+ Model output to validate.
84
+
85
+ Returns
86
+ -------
87
+ list[str]
88
+ List of validation error messages. Empty list if valid.
89
+
90
+ Examples
91
+ --------
92
+ >>> errors = validate_model_output(output)
93
+ >>> if not errors:
94
+ ... print("Model output is valid")
95
+ """
96
+ errors: list[str] = []
97
+
98
+ # Check required fields are not empty
99
+ if not output.model_name or not output.model_name.strip():
100
+ errors.append("Model output has empty model_name")
101
+
102
+ if not output.operation or not output.operation.strip():
103
+ errors.append("Model output has empty operation")
104
+
105
+ if not output.cache_key or not output.cache_key.strip():
106
+ errors.append("Model output has empty cache_key")
107
+
108
+ # Check operation-specific output structure
109
+ if output.operation == "nli":
110
+ # NLI should return dict with entailment/neutral/contradiction
111
+ if not isinstance(output.output, dict):
112
+ errors.append(f"NLI output should be dict, got {type(output.output)}")
113
+ else:
114
+ expected_keys = {"entailment", "neutral", "contradiction"}
115
+ actual_keys = set(output.output.keys()) # type: ignore[union-attr]
116
+ if actual_keys != expected_keys:
117
+ errors.append(
118
+ f"NLI output keys mismatch: expected {expected_keys}, "
119
+ f"got {actual_keys}"
120
+ )
121
+
122
+ elif output.operation in ("log_probability", "perplexity", "similarity"):
123
+ # These should return numeric values
124
+ if not isinstance(output.output, int | float):
125
+ errors.append(
126
+ f"{output.operation} output should be numeric, "
127
+ f"got {type(output.output)}"
128
+ )
129
+
130
+ elif output.operation == "embedding":
131
+ # Should return list or array
132
+ if not isinstance(output.output, list | dict):
133
+ # dict could be serialized ndarray
134
+ errors.append(
135
+ f"Embedding output should be list/array, got {type(output.output)}"
136
+ )
137
+
138
+ return errors
139
+
140
+
141
+ def validate_constraint_satisfaction(
142
+ item: Item, item_template: ItemTemplate
143
+ ) -> list[str]:
144
+ """Validate constraint satisfaction consistency.
145
+
146
+ Check that all constraints in the template have been evaluated and
147
+ that the results are boolean values.
148
+
149
+ Parameters
150
+ ----------
151
+ item : Item
152
+ Item to validate.
153
+ item_template : ItemTemplate
154
+ Template with constraints.
155
+
156
+ Returns
157
+ -------
158
+ list[str]
159
+ List of validation error messages. Empty list if valid.
160
+
161
+ Examples
162
+ --------
163
+ >>> errors = validate_constraint_satisfaction(item, template)
164
+ >>> if not errors:
165
+ ... print("Constraint satisfaction is valid")
166
+ """
167
+ errors: list[str] = []
168
+
169
+ # Check all template constraints are evaluated
170
+ for constraint_id in item_template.constraints:
171
+ if constraint_id not in item.constraint_satisfaction:
172
+ errors.append(f"Constraint {constraint_id} not evaluated")
173
+ else:
174
+ # Check value is boolean
175
+ value = item.constraint_satisfaction[constraint_id]
176
+ if type(value) is not bool:
177
+ errors.append(
178
+ f"Constraint {constraint_id} satisfaction should be bool, "
179
+ f"got {type(value)}"
180
+ )
181
+
182
+ return errors
183
+
184
+
185
+ def validate_metadata_completeness(item: Item) -> list[str]:
186
+ """Validate that item metadata is complete.
187
+
188
+ Check that the item has all expected metadata fields populated.
189
+ Since Item inherits from BeadBaseModel, id, created_at, and modified_at
190
+ are always present. This function is kept for consistency and future
191
+ extensibility.
192
+
193
+ Parameters
194
+ ----------
195
+ item : Item
196
+ Item to validate.
197
+
198
+ Returns
199
+ -------
200
+ list[str]
201
+ List of validation error messages. Empty list if valid.
202
+
203
+ Examples
204
+ --------
205
+ >>> errors = validate_metadata_completeness(item)
206
+ >>> if not errors:
207
+ ... print("Metadata is complete")
208
+ """
209
+ errors: list[str] = []
210
+
211
+ # Check base model fields (from BeadBaseModel)
212
+ # These are always present due to Pydantic model initialization,
213
+ # but we check for completeness
214
+ if not hasattr(item, "id"):
215
+ errors.append("Item missing id field") # pragma: no cover
216
+
217
+ if not hasattr(item, "created_at"):
218
+ errors.append("Item missing created_at timestamp") # pragma: no cover
219
+
220
+ if not hasattr(item, "modified_at"):
221
+ errors.append("Item missing modified_at timestamp") # pragma: no cover
222
+
223
+ return errors
224
+
225
+
226
+ def item_passes_all_constraints(item: Item) -> bool:
227
+ """Check if item satisfies all constraints.
228
+
229
+ Convenience function to check if all constraints are satisfied.
230
+
231
+ Parameters
232
+ ----------
233
+ item : Item
234
+ Item to check.
235
+
236
+ Returns
237
+ -------
238
+ bool
239
+ True if all constraints satisfied, False otherwise.
240
+
241
+ Examples
242
+ --------
243
+ >>> if item_passes_all_constraints(item):
244
+ ... print("Item is valid")
245
+ """
246
+ return all(item.constraint_satisfaction.values())
247
+
248
+
249
+ def _check_options(item: Item) -> tuple[bool, int]:
250
+ """Check if item has valid options list.
251
+
252
+ Helper function for detecting forced_choice and multi_select task types.
253
+ Checks the item.options field for a valid list of options.
254
+
255
+ Parameters
256
+ ----------
257
+ item : Item
258
+ Item to check for options.
259
+
260
+ Returns
261
+ -------
262
+ tuple[bool, int]
263
+ Tuple of (has_options, n_options) where has_options is True if
264
+ the item has at least 2 options, and n_options is the count.
265
+
266
+ Examples
267
+ --------
268
+ >>> item = Item(item_template_id=uuid4(), options=["A", "B"])
269
+ >>> _check_options(item)
270
+ (True, 2)
271
+ >>> item = Item(item_template_id=uuid4(), options=[])
272
+ >>> _check_options(item)
273
+ (False, 0)
274
+ >>> item = Item(item_template_id=uuid4(), options=["A"])
275
+ >>> _check_options(item)
276
+ (False, 0) # Need at least 2 options
277
+ """
278
+ if not item.options:
279
+ return (False, 0)
280
+
281
+ n_options = len(item.options)
282
+
283
+ # Must have at least 2 options to be valid
284
+ if n_options < 2:
285
+ return (False, 0)
286
+
287
+ return (True, n_options)
288
+
289
+
290
+ def _check_option_keys( # pyright: ignore[reportUnusedFunction]
291
+ rendered_elements: dict[str, str],
292
+ ) -> tuple[bool, int]:
293
+ """Check if rendered_elements has consecutive option_a, option_b, ... keys.
294
+
295
+ .. deprecated::
296
+ This function is deprecated. Use _check_options() instead, which
297
+ checks the item.options list field.
298
+
299
+ Helper function for detecting forced_choice and multi_select task types
300
+ in legacy items that store options in rendered_elements.
301
+
302
+ Parameters
303
+ ----------
304
+ rendered_elements : dict
305
+ Dictionary of rendered elements to check.
306
+
307
+ Returns
308
+ -------
309
+ tuple[bool, int]
310
+ Tuple of (has_options, n_options) where has_options is True if
311
+ consecutive option keys found, and n_options is the count.
312
+
313
+ Examples
314
+ --------
315
+ >>> _check_option_keys({"option_a": "A", "option_b": "B"})
316
+ (True, 2)
317
+ >>> _check_option_keys({"text": "Hello"})
318
+ (False, 0)
319
+ >>> _check_option_keys({"option_a": "A", "option_c": "C"})
320
+ (False, 0) # Not consecutive
321
+ """
322
+ # Check for option_a, option_b, option_c, ...
323
+ if "option_a" not in rendered_elements:
324
+ return (False, 0)
325
+
326
+ # Count consecutive options starting from option_a
327
+ n_options = 0
328
+ expected_letters = "abcdefghijklmnopqrstuvwxyz"
329
+
330
+ for letter in expected_letters:
331
+ key = f"option_{letter}"
332
+ if key in rendered_elements:
333
+ n_options += 1
334
+ else:
335
+ break
336
+
337
+ # Must have at least 2 options to be valid
338
+ return (n_options >= 2, n_options)
339
+
340
+
341
+ def get_task_type_requirements(task_type: TaskType) -> dict[str, list[str] | str]:
342
+ """Get validation requirements for a task type.
343
+
344
+ Returns a dictionary describing the structural requirements
345
+ for items of the specified task type. Useful for introspection,
346
+ error messages, and documentation generation.
347
+
348
+ Parameters
349
+ ----------
350
+ task_type : TaskType
351
+ Task type to get requirements for.
352
+
353
+ Returns
354
+ -------
355
+ dict
356
+ Requirements specification with keys:
357
+ - required_rendered_keys: List of required rendered_elements keys
358
+ - required_metadata_keys: List of required item_metadata keys
359
+ - optional_metadata_keys: List of optional item_metadata keys
360
+ - special_fields: List of special fields (e.g., ["unfilled_slots"])
361
+ - description: Human-readable description
362
+
363
+ Examples
364
+ --------
365
+ >>> reqs = get_task_type_requirements("ordinal_scale")
366
+ >>> print(reqs["required_rendered_keys"])
367
+ ['text']
368
+ >>> print(reqs["required_metadata_keys"])
369
+ ['scale_min', 'scale_max']
370
+ """
371
+ requirements = {
372
+ "forced_choice": {
373
+ "required_rendered_keys": [],
374
+ "required_metadata_keys": [],
375
+ "optional_metadata_keys": [
376
+ "source_items",
377
+ "group_key",
378
+ "pair_type",
379
+ "n_options",
380
+ ],
381
+ "special_fields": ["options"],
382
+ "description": (
383
+ "Pick exactly one option from N alternatives (2AFC, 3AFC, ...)"
384
+ ),
385
+ },
386
+ "multi_select": {
387
+ "required_rendered_keys": [],
388
+ "required_metadata_keys": ["min_selections", "max_selections"],
389
+ "optional_metadata_keys": ["source_items", "group_key"],
390
+ "special_fields": ["options"],
391
+ "description": "Pick one or more options (checkboxes)",
392
+ },
393
+ "ordinal_scale": {
394
+ "required_rendered_keys": ["text", "prompt"],
395
+ "required_metadata_keys": ["scale_min", "scale_max"],
396
+ "optional_metadata_keys": ["source_items", "group_key", "scale_labels"],
397
+ "special_fields": [],
398
+ "description": "Value on ordered discrete scale (Likert, slider)",
399
+ },
400
+ "magnitude": {
401
+ "required_rendered_keys": ["text", "prompt"],
402
+ "required_metadata_keys": ["min_value", "max_value"],
403
+ "optional_metadata_keys": [
404
+ "unit",
405
+ "step",
406
+ "source_items",
407
+ "group_key",
408
+ ],
409
+ "special_fields": [],
410
+ "description": "Unbounded numeric value (reading time, confidence)",
411
+ },
412
+ "binary": {
413
+ "required_rendered_keys": ["text", "prompt"],
414
+ "required_metadata_keys": [],
415
+ "optional_metadata_keys": ["binary_options", "source_items", "group_key"],
416
+ "special_fields": [],
417
+ "description": "Yes/No, True/False (absolute judgment)",
418
+ },
419
+ "categorical": {
420
+ "required_rendered_keys": ["text", "prompt"],
421
+ "required_metadata_keys": ["categories"],
422
+ "optional_metadata_keys": ["source_items", "group_key"],
423
+ "special_fields": [],
424
+ "description": "Pick from unordered categories (NLI, semantic relations)",
425
+ },
426
+ "free_text": {
427
+ "required_rendered_keys": ["text", "prompt"],
428
+ "required_metadata_keys": [],
429
+ "optional_metadata_keys": [
430
+ "max_length",
431
+ "validation_pattern",
432
+ "multiline",
433
+ "source_items",
434
+ "group_key",
435
+ ],
436
+ "special_fields": [],
437
+ "description": "Open-ended text (paraphrase, comprehension)",
438
+ },
439
+ "cloze": {
440
+ "required_rendered_keys": ["text"],
441
+ "required_metadata_keys": ["n_unfilled_slots"],
442
+ "optional_metadata_keys": ["source_items", "group_key", "template_id"],
443
+ "special_fields": ["unfilled_slots"],
444
+ "description": "Fill-in-the-blank (constraint-based UI)",
445
+ },
446
+ }
447
+
448
+ if task_type not in requirements:
449
+ raise ValueError(
450
+ f"Unknown task type: {task_type}. "
451
+ f"Expected one of: {list(requirements.keys())}"
452
+ )
453
+
454
+ return requirements[task_type]
455
+
456
+
457
+ def validate_item_for_task_type(item: Item, task_type: TaskType) -> bool:
458
+ """Validate that an Item's structure matches requirements for a task type.
459
+
460
+ Checks that the item has the required rendered_elements keys,
461
+ item_metadata keys, and special fields for the specified task type.
462
+ Raises descriptive ValueError if validation fails.
463
+
464
+ Parameters
465
+ ----------
466
+ item : Item
467
+ Item to validate.
468
+ task_type : TaskType
469
+ Expected task type (from bead.items.item_template.TaskType).
470
+
471
+ Returns
472
+ -------
473
+ bool
474
+ True if valid.
475
+
476
+ Raises
477
+ ------
478
+ ValueError
479
+ If item structure doesn't match task type requirements,
480
+ with detailed explanation of what's wrong.
481
+
482
+ Examples
483
+ --------
484
+ >>> from bead.items.ordinal_scale import create_ordinal_scale_item
485
+ >>> item = create_ordinal_scale_item("How natural?", scale_bounds=(1, 7))
486
+ >>> validate_item_for_task_type(item, "ordinal_scale")
487
+ True
488
+
489
+ >>> from bead.items.forced_choice import create_forced_choice_item
490
+ >>> fc_item = create_forced_choice_item("A", "B")
491
+ >>> validate_item_for_task_type(fc_item, "ordinal_scale")
492
+ ValueError: ordinal_scale items must have 'text' in rendered_elements...
493
+ """
494
+ reqs = get_task_type_requirements(task_type)
495
+
496
+ # Check rendered_elements keys
497
+ actual_rendered = set(item.rendered_elements.keys())
498
+ required_rendered = set(reqs["required_rendered_keys"])
499
+
500
+ # Special handling for forced_choice and multi_select (options field)
501
+ if task_type in ("forced_choice", "multi_select"):
502
+ has_options, n_options = _check_options(item)
503
+ if not has_options:
504
+ raise ValueError(
505
+ f"{task_type} items must have at least 2 options in the options field, "
506
+ f"but found {n_options} option(s): {item.options}"
507
+ )
508
+ # For these types, we don't check for exact required rendered_elements keys
509
+ else:
510
+ # Check for exact required keys
511
+ missing_rendered = required_rendered - actual_rendered
512
+ if missing_rendered:
513
+ raise ValueError(
514
+ f"{task_type} items must have {list(required_rendered)} "
515
+ f"in rendered_elements, but missing: {list(missing_rendered)}. "
516
+ f"Found keys: {list(actual_rendered)}"
517
+ )
518
+
519
+ # Check item_metadata keys
520
+ actual_metadata = set(item.item_metadata.keys())
521
+ required_metadata = set(reqs["required_metadata_keys"])
522
+
523
+ missing_metadata = required_metadata - actual_metadata
524
+ if missing_metadata:
525
+ raise ValueError(
526
+ f"{task_type} items must have {list(required_metadata)} "
527
+ f"in item_metadata, but missing: {list(missing_metadata)}. "
528
+ f"Found keys: {list(actual_metadata)}"
529
+ )
530
+
531
+ # Check special fields
532
+ if "unfilled_slots" in reqs["special_fields"]:
533
+ if not item.unfilled_slots:
534
+ raise ValueError(
535
+ f"{task_type} items must have unfilled_slots field populated, "
536
+ f"but found empty list"
537
+ )
538
+
539
+ if "options" in reqs["special_fields"]:
540
+ if not item.options or len(item.options) < 2:
541
+ raise ValueError(
542
+ f"{task_type} items must have at least 2 options in the options field, "
543
+ f"but found {len(item.options) if item.options else 0} option(s)"
544
+ )
545
+
546
+ # Task-specific validation
547
+ if task_type == "ordinal_scale":
548
+ scale_min = item.item_metadata.get("scale_min")
549
+ scale_max = item.item_metadata.get("scale_max")
550
+ if not isinstance(scale_min, int) or not isinstance(scale_max, int):
551
+ raise ValueError(
552
+ f"ordinal_scale items must have integer scale_min and scale_max, "
553
+ f"but got scale_min={type(scale_min).__name__}, "
554
+ f"scale_max={type(scale_max).__name__}"
555
+ )
556
+ if scale_min >= scale_max:
557
+ raise ValueError(
558
+ f"ordinal_scale items must have scale_min < scale_max, "
559
+ f"but got scale_min={scale_min}, scale_max={scale_max}"
560
+ )
561
+
562
+ if task_type == "multi_select":
563
+ min_sel = item.item_metadata.get("min_selections")
564
+ max_sel = item.item_metadata.get("max_selections")
565
+ if not isinstance(min_sel, int) or not isinstance(max_sel, int):
566
+ raise ValueError(
567
+ "multi_select items must have integer min_selections "
568
+ f"and max_selections, but got min_selections="
569
+ f"{type(min_sel).__name__}, max_selections="
570
+ f"{type(max_sel).__name__}"
571
+ )
572
+ if min_sel <= 0 or max_sel <= 0:
573
+ raise ValueError(
574
+ "multi_select items must have positive min_selections "
575
+ f"and max_selections, but got min_selections={min_sel}, "
576
+ f"max_selections={max_sel}"
577
+ )
578
+ if min_sel > max_sel:
579
+ raise ValueError(
580
+ f"multi_select items must have min_selections <= max_selections, "
581
+ f"but got min_selections={min_sel}, max_selections={max_sel}"
582
+ )
583
+
584
+ if task_type == "magnitude":
585
+ min_val = item.item_metadata.get("min_value")
586
+ max_val = item.item_metadata.get("max_value")
587
+ if min_val is not None and max_val is not None:
588
+ if not isinstance(min_val, int | float) or not isinstance(
589
+ max_val, int | float
590
+ ):
591
+ raise ValueError(
592
+ "magnitude items with bounds must have numeric "
593
+ f"min_value and max_value, but got min_value="
594
+ f"{type(min_val).__name__}, max_value="
595
+ f"{type(max_val).__name__}"
596
+ )
597
+ if min_val >= max_val:
598
+ raise ValueError(
599
+ f"magnitude items must have min_value < max_value, "
600
+ f"but got min_value={min_val}, max_value={max_val}"
601
+ )
602
+
603
+ if task_type in ("binary", "categorical", "free_text"):
604
+ prompt = item.rendered_elements.get("prompt")
605
+ if not prompt or not str(prompt).strip():
606
+ raise ValueError(
607
+ f"{task_type} items must have non-empty 'prompt' in rendered_elements"
608
+ )
609
+
610
+ if task_type == "categorical":
611
+ categories = item.item_metadata.get("categories")
612
+ if not isinstance(categories, list) or len(categories) == 0:
613
+ raise ValueError(
614
+ "categorical items must have non-empty list in "
615
+ f"item_metadata['categories'], but got "
616
+ f"{type(categories).__name__}"
617
+ )
618
+
619
+ # No additional validation needed for forced_choice
620
+ # (n_options is optional metadata, not required)
621
+
622
+ return True
623
+
624
+
625
+ def infer_task_type_from_item(item: Item) -> TaskType:
626
+ """Infer most likely task type from Item structure.
627
+
628
+ Examines the item's rendered_elements, item_metadata, and special fields
629
+ to determine which task type it matches. Uses priority order to handle
630
+ ambiguous cases.
631
+
632
+ Parameters
633
+ ----------
634
+ item : Item
635
+ Item to infer from.
636
+
637
+ Returns
638
+ -------
639
+ TaskType
640
+ Inferred task type.
641
+
642
+ Raises
643
+ ------
644
+ ValueError
645
+ If item structure doesn't match any task type or is ambiguous.
646
+
647
+ Examples
648
+ --------
649
+ >>> from bead.items.ordinal_scale import create_likert_7_item
650
+ >>> item = create_likert_7_item("How natural is this sentence?")
651
+ >>> infer_task_type_from_item(item)
652
+ 'ordinal_scale'
653
+
654
+ >>> from bead.items.categorical import create_nli_item
655
+ >>> item2 = create_nli_item("All dogs bark", "Some dogs bark")
656
+ >>> infer_task_type_from_item(item2)
657
+ 'categorical'
658
+ """
659
+ rendered = item.rendered_elements
660
+ metadata = item.item_metadata
661
+
662
+ # Priority 1: Check for cloze (unique unfilled_slots field)
663
+ if item.unfilled_slots:
664
+ if "n_unfilled_slots" in metadata:
665
+ return "cloze"
666
+
667
+ # Priority 2: Check for forced_choice/multi_select (options list field)
668
+ has_options, _ = _check_options(item)
669
+ if has_options:
670
+ # Distinguish between forced_choice and multi_select
671
+ if "min_selections" in metadata and "max_selections" in metadata:
672
+ return "multi_select"
673
+ if "n_options" in metadata:
674
+ return "forced_choice"
675
+ # Default to forced_choice if has options but no specific metadata
676
+ return "forced_choice"
677
+
678
+ # Priority 3: Check for single "text" key (cloze without unfilled_slots)
679
+ if "text" in rendered and len(rendered) == 1:
680
+ # Must be cloze if only "text" key exists
681
+ # (but we already checked unfilled_slots above)
682
+ # Ambiguous: could be improperly constructed item
683
+ raise ValueError(
684
+ "Item has single 'text' key without unfilled_slots. "
685
+ "If this is a cloze item, ensure unfilled_slots is populated. "
686
+ "Other task types require additional keys."
687
+ )
688
+
689
+ # Priority 4: Check for text + prompt
690
+ # (ordinal_scale, magnitude, binary, categorical, free_text)
691
+ if "text" in rendered and "prompt" in rendered:
692
+ # Ordinal scale has scale_min/scale_max
693
+ if "scale_min" in metadata and "scale_max" in metadata:
694
+ return "ordinal_scale"
695
+ # Magnitude has min_value/max_value (always set, may be None)
696
+ if "min_value" in metadata and "max_value" in metadata:
697
+ return "magnitude"
698
+ # Categorical has categories
699
+ if "categories" in metadata:
700
+ return "categorical"
701
+ # Binary may have binary_options
702
+ if "binary_options" in metadata:
703
+ return "binary"
704
+ # Free text may have max_length, validation_pattern, or multiline
705
+ if (
706
+ "max_length" in metadata
707
+ or "validation_pattern" in metadata
708
+ or "multiline" in metadata
709
+ ):
710
+ return "free_text"
711
+ # Could be binary or free_text (most ambiguous case)
712
+ raise ValueError(
713
+ "Could be binary or free_text based on structure. "
714
+ "Item has 'text' and 'prompt' but no distinguishing metadata. "
715
+ "Use explicit task type validation."
716
+ )
717
+
718
+ # No match
719
+ raise ValueError(
720
+ f"Could not infer task type from item structure. "
721
+ f"rendered_elements keys: {list(rendered.keys())}, "
722
+ f"item_metadata keys: {list(metadata.keys())}"
723
+ )