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,787 @@
1
+ """Data models for experimental item templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+ from uuid import UUID
7
+
8
+ from pydantic import Field, ValidationInfo, field_validator
9
+
10
+ from bead.data.base import BeadBaseModel
11
+
12
+ # Type aliases for JSON-serializable metadata values
13
+ type MetadataValue = (
14
+ str | int | float | bool | None | dict[str, MetadataValue] | list[MetadataValue]
15
+ )
16
+
17
+
18
+ # Factory functions for default values with explicit types
19
+ def _empty_item_element_list() -> list[ItemElement]:
20
+ """Return empty ItemElement list."""
21
+ return []
22
+
23
+
24
+ def _empty_item_template_list() -> list[ItemTemplate]:
25
+ """Return empty ItemTemplate list."""
26
+ return []
27
+
28
+
29
+ def _empty_metadata_dict() -> dict[str, MetadataValue]:
30
+ """Return empty metadata dict."""
31
+ return {}
32
+
33
+
34
+ def _empty_display_format_dict() -> dict[str, str | int | float | bool]:
35
+ """Return empty display format dict."""
36
+ return {}
37
+
38
+
39
+ def _empty_uuid_list() -> list[UUID]:
40
+ """Return empty UUID list."""
41
+ return []
42
+
43
+
44
+ # Type aliases for judgment and task types
45
+ JudgmentType = Literal[
46
+ "acceptability", # Linguistic acceptability/grammaticality/naturalness
47
+ "inference", # Semantic relationship (NLI: entailment/neutral/contradiction)
48
+ "similarity", # Semantic similarity/distance/relatedness
49
+ "plausibility", # Likelihood/plausibility of events or statements
50
+ "comprehension", # Understanding/recall of content
51
+ "preference", # Subjective preference between alternatives
52
+ ]
53
+
54
+ TaskType = Literal[
55
+ "forced_choice", # Pick exactly one option (UI: radio buttons)
56
+ "multi_select", # Pick one or more options (UI: checkboxes)
57
+ "ordinal_scale", # Value on ordered discrete scale (UI: Likert, slider)
58
+ "magnitude", # Unbounded numeric value (UI: number input)
59
+ "binary", # Yes/no, true/false (UI: toggle, buttons)
60
+ "categorical", # Pick from unordered categories (UI: dropdown, radio)
61
+ "free_text", # Open-ended text (UI: text input, textarea)
62
+ "cloze", # Fill-in-the-blank with unfilled slots (UI: inferred)
63
+ ]
64
+
65
+ ElementRefType = Literal["text", "filled_template_ref"]
66
+
67
+ PresentationMode = Literal["static", "self_paced", "timed_sequence"]
68
+
69
+ ChunkingUnit = Literal[
70
+ "character",
71
+ "word",
72
+ "sentence",
73
+ "constituent",
74
+ "custom",
75
+ ]
76
+
77
+ ParseType = Literal["constituency", "dependency"]
78
+
79
+
80
+ class ChunkingSpec(BeadBaseModel):
81
+ """Specification for text segmentation in incremental presentation.
82
+
83
+ Defines how to segment text for self-paced reading or timed sequence
84
+ presentation. Supports character-level, word-level, sentence-level,
85
+ constituent-based (with parsing), or custom boundary segmentation.
86
+
87
+ Attributes
88
+ ----------
89
+ unit : ChunkingUnit
90
+ Segmentation unit type. Defaults to "word".
91
+ parse_type : ParseType | None
92
+ Type of parsing for constituent chunking ("constituency" or "dependency").
93
+ constituent_labels : list[str] | None
94
+ Labels for constituent chunking. For constituency parsing, these are
95
+ constituent types (e.g., ["NP", "VP", "S"]). For dependency parsing,
96
+ these are dependency relations (e.g., ["nsubj", "dobj", "root"]).
97
+ parser : Literal["stanza", "spacy"] | None
98
+ Parser library to use for constituent chunking.
99
+ parse_language : str | None
100
+ ISO 639 language code for parser (e.g., "en", "es", "zh").
101
+ custom_boundaries : list[int] | None
102
+ Token indices for custom chunking boundaries.
103
+
104
+ Examples
105
+ --------
106
+ >>> # Word-by-word chunking
107
+ >>> ChunkingSpec(unit="word")
108
+ >>> # Chunk by noun phrases (constituency)
109
+ >>> ChunkingSpec(
110
+ ... unit="constituent",
111
+ ... parse_type="constituency",
112
+ ... constituent_labels=["NP"],
113
+ ... parser="stanza",
114
+ ... parse_language="en"
115
+ ... )
116
+ >>> # Chunk by subjects and objects (dependency)
117
+ >>> ChunkingSpec(
118
+ ... unit="constituent",
119
+ ... parse_type="dependency",
120
+ ... constituent_labels=["nsubj", "dobj"],
121
+ ... parser="spacy",
122
+ ... parse_language="en"
123
+ ... )
124
+ >>> # Custom boundaries at specific token positions
125
+ >>> ChunkingSpec(unit="custom", custom_boundaries=[0, 3, 7, 10])
126
+ """
127
+
128
+ unit: ChunkingUnit = Field(default="word", description="Segmentation unit type")
129
+ parse_type: ParseType | None = Field(
130
+ default=None, description="Parsing type for constituent chunking"
131
+ )
132
+ constituent_labels: list[str] | None = Field(
133
+ default=None,
134
+ description="Constituent or dependency labels for chunking",
135
+ )
136
+ parser: Literal["stanza", "spacy"] | None = Field(
137
+ default=None, description="Parser library"
138
+ )
139
+ parse_language: str | None = Field(
140
+ default=None, description="ISO 639 language code"
141
+ )
142
+ custom_boundaries: list[int] | None = Field(
143
+ default=None, description="Custom token boundary indices"
144
+ )
145
+
146
+
147
+ class TimingParams(BeadBaseModel):
148
+ """Timing parameters for stimulus presentation.
149
+
150
+ Defines timing constraints for timed sequence presentations,
151
+ including per-chunk duration, inter-stimulus intervals, and
152
+ response timeouts.
153
+
154
+ Attributes
155
+ ----------
156
+ duration_ms : int | None
157
+ Duration in milliseconds to display each chunk (for timed sequences).
158
+ isi_ms : int | None
159
+ Inter-stimulus interval in milliseconds between chunks.
160
+ timeout_ms : int | None
161
+ Maximum time in milliseconds to wait for response.
162
+ mask_char : str | None
163
+ Character to use for masking non-current chunks (e.g., "_").
164
+ cumulative : bool
165
+ If True, show all previous chunks; if False, show only current chunk.
166
+
167
+ Examples
168
+ --------
169
+ >>> # RSVP (Rapid Serial Visual Presentation)
170
+ >>> TimingParams(
171
+ ... duration_ms=250,
172
+ ... isi_ms=50,
173
+ ... cumulative=False,
174
+ ... mask_char="_"
175
+ ... )
176
+ >>> # Self-paced with timeout
177
+ >>> TimingParams(timeout_ms=5000, cumulative=True)
178
+ """
179
+
180
+ duration_ms: int | None = Field(
181
+ default=None, description="Per-chunk display duration (ms)"
182
+ )
183
+ isi_ms: int | None = Field(default=None, description="Inter-stimulus interval (ms)")
184
+ timeout_ms: int | None = Field(default=None, description="Response timeout (ms)")
185
+ mask_char: str | None = Field(default=None, description="Masking character")
186
+ cumulative: bool = Field(
187
+ default=True, description="Show all previous chunks or only current"
188
+ )
189
+
190
+
191
+ class TaskSpec(BeadBaseModel):
192
+ """Parameters for the response collection task.
193
+
194
+ Specifies task-specific parameters like prompts, options, scale bounds,
195
+ validation rules, etc. The appropriate parameters depend on the task_type
196
+ specified in ItemTemplate. The task_type itself is not included here since
197
+ it's part of the ItemTemplate structure.
198
+
199
+ Attributes
200
+ ----------
201
+ prompt : str
202
+ Question or instruction shown to participants.
203
+ scale_bounds : tuple[int, int] | None
204
+ Min and max values for ordinal_scale task.
205
+ scale_labels : dict[int, str] | None
206
+ Optional labels for specific scale points (ordinal_scale).
207
+ options : list[str] | None
208
+ Available options for forced_choice, multi_select, or categorical tasks.
209
+ For forced_choice/multi_select: element names to choose from.
210
+ For categorical: category labels.
211
+ min_selections : int | None
212
+ Minimum number of selections required (multi_select only).
213
+ max_selections : int | None
214
+ Maximum number of selections allowed (multi_select only).
215
+ text_validation_pattern : str | None
216
+ Regular expression pattern for validating free_text responses.
217
+ max_length : int | None
218
+ Maximum character length for free_text responses.
219
+
220
+ Examples
221
+ --------
222
+ >>> # Ordinal scale task (e.g., acceptability rating)
223
+ >>> TaskSpec(
224
+ ... prompt="How natural does this sentence sound?",
225
+ ... scale_bounds=(1, 7),
226
+ ... scale_labels={1: "Very unnatural", 7: "Very natural"}
227
+ ... )
228
+ >>> # Categorical task (e.g., NLI)
229
+ >>> TaskSpec(
230
+ ... prompt="What is the relationship?",
231
+ ... options=["Entailment", "Neutral", "Contradiction"]
232
+ ... )
233
+ >>> # Binary task
234
+ >>> TaskSpec(
235
+ ... prompt="Is this sentence grammatical?"
236
+ ... )
237
+ >>> # Forced choice task (e.g., minimal pair)
238
+ >>> TaskSpec(
239
+ ... prompt="Which sounds more natural?",
240
+ ... options=["sentence_a", "sentence_b"]
241
+ ... )
242
+ >>> # Multi-select task (e.g., select all grammatical)
243
+ >>> TaskSpec(
244
+ ... prompt="Select all grammatical sentences:",
245
+ ... options=["sent_a", "sent_b", "sent_c"],
246
+ ... min_selections=1
247
+ ... )
248
+ >>> # Free text task
249
+ >>> TaskSpec(
250
+ ... prompt="Who performed the action?",
251
+ ... max_length=50
252
+ ... )
253
+ """
254
+
255
+ prompt: str = Field(..., description="Participant prompt/question")
256
+ scale_bounds: tuple[int, int] | None = Field(
257
+ default=None, description="Scale bounds for ordinal_scale task"
258
+ )
259
+ scale_labels: dict[int, str] | None = Field(
260
+ default=None, description="Labels for scale points"
261
+ )
262
+ options: list[str] | None = Field(
263
+ default=None,
264
+ description="Options for forced_choice/multi_select/categorical tasks",
265
+ )
266
+ min_selections: int | None = Field(
267
+ default=None, description="Minimum selections for multi_select task"
268
+ )
269
+ max_selections: int | None = Field(
270
+ default=None, description="Maximum selections for multi_select task"
271
+ )
272
+ text_validation_pattern: str | None = Field(
273
+ default=None, description="Regex pattern for text validation"
274
+ )
275
+ max_length: int | None = Field(default=None, description="Maximum text length")
276
+
277
+ @field_validator("prompt")
278
+ @classmethod
279
+ def validate_prompt(cls, v: str) -> str:
280
+ """Validate prompt is not empty.
281
+
282
+ Parameters
283
+ ----------
284
+ v : str
285
+ Prompt to validate.
286
+
287
+ Returns
288
+ -------
289
+ str
290
+ Validated prompt.
291
+
292
+ Raises
293
+ ------
294
+ ValueError
295
+ If prompt is empty or contains only whitespace.
296
+ """
297
+ if not v or not v.strip():
298
+ raise ValueError("Prompt cannot be empty")
299
+ return v.strip()
300
+
301
+
302
+ class PresentationSpec(BeadBaseModel):
303
+ """Specification of stimulus presentation method.
304
+
305
+ Defines how stimuli are displayed to participants (static, self-paced,
306
+ or timed sequence), including segmentation and timing parameters.
307
+ Separate from judgment specification to maintain clean separation
308
+ of concerns.
309
+
310
+ Attributes
311
+ ----------
312
+ mode : PresentationMode
313
+ Presentation mode (static, self_paced, or timed_sequence). Defaults to
314
+ "static".
315
+ chunking : ChunkingSpec
316
+ Chunking specification for incremental presentations. Defaults to
317
+ word-level chunking.
318
+ timing : TimingParams
319
+ Timing parameters for timed presentations. Defaults to cumulative
320
+ display with no fixed durations.
321
+ display_format : dict[str, str | int | float | bool]
322
+ Additional display formatting options.
323
+
324
+ Examples
325
+ --------
326
+ >>> # Static presentation (default)
327
+ >>> PresentationSpec()
328
+ >>> # Self-paced word-by-word reading
329
+ >>> PresentationSpec(
330
+ ... mode="self_paced",
331
+ ... chunking=ChunkingSpec(unit="word")
332
+ ... )
333
+ >>> # Self-paced by noun phrases
334
+ >>> PresentationSpec(
335
+ ... mode="self_paced",
336
+ ... chunking=ChunkingSpec(
337
+ ... unit="constituent",
338
+ ... parse_type="constituency",
339
+ ... constituent_labels=["NP"],
340
+ ... parser="stanza",
341
+ ... parse_language="en"
342
+ ... )
343
+ ... )
344
+ >>> # RSVP (timed sequence)
345
+ >>> PresentationSpec(
346
+ ... mode="timed_sequence",
347
+ ... chunking=ChunkingSpec(unit="word"),
348
+ ... timing=TimingParams(duration_ms=250, isi_ms=50, cumulative=False)
349
+ ... )
350
+ """
351
+
352
+ mode: PresentationMode = Field(default="static", description="Presentation mode")
353
+ chunking: ChunkingSpec = Field(
354
+ default_factory=ChunkingSpec, description="Chunking specification"
355
+ )
356
+ timing: TimingParams = Field(
357
+ default_factory=TimingParams, description="Timing parameters"
358
+ )
359
+ display_format: dict[str, str | int | float | bool] = Field(
360
+ default_factory=_empty_display_format_dict,
361
+ description="Display formatting options",
362
+ )
363
+
364
+
365
+ class ItemElement(BeadBaseModel):
366
+ """A structured element within an item template.
367
+
368
+ ItemElements represent distinct parts of a complex item,
369
+ such as context, target sentence, question, or response options.
370
+ Elements can be static text or references to filled templates.
371
+
372
+ Attributes
373
+ ----------
374
+ element_type : ElementRefType
375
+ Type of element ("text" or "filled_template_ref").
376
+ element_name : str
377
+ Unique name for this element within the item.
378
+ content : str | None
379
+ Static text content (for text elements).
380
+ filled_template_ref_id : UUID | None
381
+ UUID of filled template (for reference elements).
382
+ element_metadata : dict[str, MetadataValue]
383
+ Additional element-specific metadata.
384
+ order : int | None
385
+ Display order for this element (optional).
386
+
387
+ Examples
388
+ --------
389
+ >>> # Text element
390
+ >>> context = ItemElement(
391
+ ... element_type="text",
392
+ ... element_name="context",
393
+ ... content="Mary loves books.",
394
+ ... order=1
395
+ ... )
396
+ >>> # Template reference element
397
+ >>> target = ItemElement(
398
+ ... element_type="filled_template_ref",
399
+ ... element_name="target",
400
+ ... filled_template_ref_id=UUID("..."),
401
+ ... order=2
402
+ ... )
403
+ """
404
+
405
+ element_type: ElementRefType = Field(..., description="Type of element")
406
+ element_name: str = Field(..., description="Unique element name within item")
407
+ content: str | None = Field(default=None, description="Static text content")
408
+ filled_template_ref_id: UUID | None = Field(
409
+ default=None, description="Filled template reference"
410
+ )
411
+ element_metadata: dict[str, MetadataValue] = Field(
412
+ default_factory=_empty_metadata_dict, description="Element-specific metadata"
413
+ )
414
+ order: int | None = Field(
415
+ default=None, description="Display order for this element"
416
+ )
417
+
418
+ @field_validator("element_name")
419
+ @classmethod
420
+ def validate_element_name(cls, v: str) -> str:
421
+ """Validate element name is not empty.
422
+
423
+ Parameters
424
+ ----------
425
+ v : str
426
+ Element name to validate.
427
+
428
+ Returns
429
+ -------
430
+ str
431
+ Validated element name.
432
+
433
+ Raises
434
+ ------
435
+ ValueError
436
+ If name is empty or contains only whitespace.
437
+ """
438
+ if not v or not v.strip():
439
+ raise ValueError("Element name cannot be empty")
440
+ return v.strip()
441
+
442
+ @property
443
+ def is_text(self) -> bool:
444
+ """Check if this is a text element.
445
+
446
+ Returns
447
+ -------
448
+ bool
449
+ True if element_type is "text".
450
+ """
451
+ return self.element_type == "text"
452
+
453
+ @property
454
+ def is_template_ref(self) -> bool:
455
+ """Check if this references a filled template.
456
+
457
+ Returns
458
+ -------
459
+ bool
460
+ True if element_type is "filled_template_ref".
461
+ """
462
+ return self.element_type == "filled_template_ref"
463
+
464
+
465
+ class ItemTemplate(BeadBaseModel):
466
+ """Template specification for constructing experimental items.
467
+
468
+ ItemTemplate defines how to construct an experimental item with three
469
+ orthogonal dimensions: what semantic property to measure (judgment_type),
470
+ how to collect the response (task_type), and how to present the stimulus
471
+ (presentation_spec).
472
+
473
+ This is distinct from Template (in bead.resources.structures), which defines
474
+ linguistic structure. ItemTemplate defines experimental structure.
475
+
476
+ Attributes
477
+ ----------
478
+ name : str
479
+ Template name (e.g., "acceptability_rating").
480
+ description : str | None
481
+ Human-readable description of this item template.
482
+ judgment_type : JudgmentType
483
+ Semantic property being measured (acceptability, inference, etc.).
484
+ task_type : TaskType
485
+ Response collection method (forced_choice, ordinal_scale, etc.).
486
+ elements : list[ItemElement]
487
+ Elements that compose this item.
488
+ constraints : list[UUID]
489
+ UUIDs of constraints on items (typically model-based).
490
+ task_spec : TaskSpec
491
+ Task-specific parameters (prompt, options, scale bounds, etc.).
492
+ presentation_spec : PresentationSpec
493
+ Specification of how to present stimuli.
494
+ presentation_order : list[str] | None
495
+ Order to present elements (by element_name).
496
+ template_metadata : dict[str, MetadataValue]
497
+ Additional template metadata.
498
+
499
+ Examples
500
+ --------
501
+ >>> # Acceptability judgment with ordinal scale task
502
+ >>> template = ItemTemplate(
503
+ ... name="acceptability_rating",
504
+ ... judgment_type="acceptability",
505
+ ... task_type="ordinal_scale",
506
+ ... task_spec=TaskSpec(
507
+ ... prompt="How natural is this sentence?",
508
+ ... scale_bounds=(1, 7),
509
+ ... scale_labels={1: "Very unnatural", 7: "Very natural"}
510
+ ... ),
511
+ ... presentation_spec=PresentationSpec(mode="static"),
512
+ ... elements=[
513
+ ... ItemElement(
514
+ ... element_type="filled_template_ref",
515
+ ... element_name="sentence",
516
+ ... filled_template_ref_id=UUID("...")
517
+ ... )
518
+ ... ]
519
+ ... )
520
+ >>> # Minimal pair: acceptability judgment with forced choice task
521
+ >>> minimal_pair = ItemTemplate(
522
+ ... name="minimal_pair",
523
+ ... judgment_type="acceptability",
524
+ ... task_type="forced_choice",
525
+ ... elements=[
526
+ ... ItemElement(
527
+ ... element_type="text", element_name="sent_a", content="Who..."
528
+ ... ),
529
+ ... ItemElement(
530
+ ... element_type="text", element_name="sent_b", content="Whom..."
531
+ ... )
532
+ ... ],
533
+ ... task_spec=TaskSpec(
534
+ ... prompt="Which sounds more natural?",
535
+ ... options=["sent_a", "sent_b"]
536
+ ... ),
537
+ ... presentation_spec=PresentationSpec(mode="static")
538
+ ... )
539
+ >>> # Odd-man-out: similarity judgment with forced choice task
540
+ >>> odd_man_out = ItemTemplate(
541
+ ... name="odd_man_out",
542
+ ... judgment_type="similarity",
543
+ ... task_type="forced_choice",
544
+ ... elements=[...], # 4 elements
545
+ ... task_spec=TaskSpec(
546
+ ... prompt="Which is most different?",
547
+ ... options=["opt_a", "opt_b", "opt_c", "opt_d"]
548
+ ... ),
549
+ ... presentation_spec=PresentationSpec(mode="static")
550
+ ... )
551
+ """
552
+
553
+ name: str = Field(..., description="Template name")
554
+ description: str | None = Field(default=None, description="Template description")
555
+ judgment_type: JudgmentType = Field(
556
+ ..., description="Semantic property being measured"
557
+ )
558
+ task_type: TaskType = Field(..., description="Response collection method")
559
+ elements: list[ItemElement] = Field(
560
+ default_factory=_empty_item_element_list, description="Item elements"
561
+ )
562
+ constraints: list[UUID] = Field(
563
+ default_factory=_empty_uuid_list, description="Constraint UUIDs"
564
+ )
565
+ task_spec: TaskSpec = Field(..., description="Task-specific parameters")
566
+ presentation_spec: PresentationSpec = Field(
567
+ ..., description="Presentation specification"
568
+ )
569
+ presentation_order: list[str] | None = Field(
570
+ default=None, description="Element presentation order"
571
+ )
572
+ template_metadata: dict[str, MetadataValue] = Field(
573
+ default_factory=_empty_metadata_dict, description="Additional metadata"
574
+ )
575
+
576
+ @field_validator("name")
577
+ @classmethod
578
+ def validate_name(cls, v: str) -> str:
579
+ """Validate template name is not empty.
580
+
581
+ Parameters
582
+ ----------
583
+ v : str
584
+ Template name to validate.
585
+
586
+ Returns
587
+ -------
588
+ str
589
+ Validated template name.
590
+
591
+ Raises
592
+ ------
593
+ ValueError
594
+ If name is empty or contains only whitespace.
595
+ """
596
+ if not v or not v.strip():
597
+ raise ValueError("Template name cannot be empty")
598
+ return v.strip()
599
+
600
+ @field_validator("elements")
601
+ @classmethod
602
+ def validate_unique_element_names(cls, v: list[ItemElement]) -> list[ItemElement]:
603
+ """Validate all element names are unique within template.
604
+
605
+ Parameters
606
+ ----------
607
+ v : list[ItemElement]
608
+ List of elements to validate.
609
+
610
+ Returns
611
+ -------
612
+ list[ItemElement]
613
+ Validated elements.
614
+
615
+ Raises
616
+ ------
617
+ ValueError
618
+ If duplicate element names found.
619
+ """
620
+ if not v:
621
+ return v
622
+
623
+ names = [elem.element_name for elem in v]
624
+ if len(names) != len(set(names)):
625
+ duplicates = [name for name in names if names.count(name) > 1]
626
+ raise ValueError(f"Duplicate element names: {set(duplicates)}")
627
+
628
+ return v
629
+
630
+ @field_validator("presentation_order", mode="after")
631
+ @classmethod
632
+ def validate_presentation_order(
633
+ cls, v: list[str] | None, info: ValidationInfo
634
+ ) -> list[str] | None:
635
+ """Validate presentation_order matches element names.
636
+
637
+ Parameters
638
+ ----------
639
+ v : list[str] | None
640
+ Presentation order list to validate.
641
+ info : ValidationInfo
642
+ Pydantic validation info containing other field values.
643
+
644
+ Returns
645
+ -------
646
+ list[str] | None
647
+ Validated presentation order.
648
+
649
+ Raises
650
+ ------
651
+ ValueError
652
+ If presentation_order contains names not in elements,
653
+ or is missing names from elements.
654
+ """
655
+ if v is None:
656
+ return v
657
+
658
+ # Get elements from validation info
659
+ elements = info.data.get("elements", [])
660
+ if not elements:
661
+ return v
662
+
663
+ element_names = {e.element_name for e in elements}
664
+ order_names = set(v)
665
+
666
+ # Check for names in order that aren't in elements
667
+ extra = order_names - element_names
668
+ if extra:
669
+ raise ValueError(
670
+ f"presentation_order contains element names not in elements: {extra}"
671
+ )
672
+
673
+ # Check for names in elements that aren't in order
674
+ missing = element_names - order_names
675
+ if missing:
676
+ raise ValueError(
677
+ f"presentation_order missing element names from elements: {missing}"
678
+ )
679
+
680
+ return v
681
+
682
+ def get_element_by_name(self, name: str) -> ItemElement | None:
683
+ """Get an element by its name.
684
+
685
+ Parameters
686
+ ----------
687
+ name : str
688
+ Element name to search for.
689
+
690
+ Returns
691
+ -------
692
+ ItemElement | None
693
+ Element with matching name, or None if not found.
694
+
695
+ Examples
696
+ --------
697
+ >>> elem = template.get_element_by_name("sentence")
698
+ >>> if elem:
699
+ ... print(elem.element_type)
700
+ """
701
+ for elem in self.elements:
702
+ if elem.element_name == name:
703
+ return elem
704
+ return None
705
+
706
+ def get_template_ref_elements(self) -> list[ItemElement]:
707
+ """Get all elements that reference filled templates.
708
+
709
+ Returns
710
+ -------
711
+ list[ItemElement]
712
+ Elements with element_type="filled_template_ref".
713
+
714
+ Examples
715
+ --------
716
+ >>> refs = template.get_template_ref_elements()
717
+ >>> print(f"Found {len(refs)} template references")
718
+ """
719
+ return [elem for elem in self.elements if elem.is_template_ref]
720
+
721
+
722
+ class ItemTemplateCollection(BeadBaseModel):
723
+ """A collection of item templates.
724
+
725
+ Attributes
726
+ ----------
727
+ name : str
728
+ Name of this collection.
729
+ description : str | None
730
+ Description of this collection.
731
+ templates : list[ItemTemplate]
732
+ Item templates in this collection.
733
+
734
+ Examples
735
+ --------
736
+ >>> collection = ItemTemplateCollection(
737
+ ... name="acceptability_study",
738
+ ... description="Templates for acceptability judgments"
739
+ ... )
740
+ >>> collection.add_template(template)
741
+ """
742
+
743
+ name: str = Field(..., description="Collection name")
744
+ description: str | None = Field(default=None, description="Collection description")
745
+ templates: list[ItemTemplate] = Field(
746
+ default_factory=_empty_item_template_list, description="Item templates"
747
+ )
748
+
749
+ @field_validator("name")
750
+ @classmethod
751
+ def validate_name(cls, v: str) -> str:
752
+ """Validate collection name is not empty.
753
+
754
+ Parameters
755
+ ----------
756
+ v : str
757
+ Collection name to validate.
758
+
759
+ Returns
760
+ -------
761
+ str
762
+ Validated collection name.
763
+
764
+ Raises
765
+ ------
766
+ ValueError
767
+ If name is empty or contains only whitespace.
768
+ """
769
+ if not v or not v.strip():
770
+ raise ValueError("Collection name cannot be empty")
771
+ return v.strip()
772
+
773
+ def add_template(self, template: ItemTemplate) -> None:
774
+ """Add a template to the collection.
775
+
776
+ Parameters
777
+ ----------
778
+ template : ItemTemplate
779
+ Template to add.
780
+
781
+ Examples
782
+ --------
783
+ >>> collection.add_template(my_template)
784
+ >>> print(f"Collection now has {len(collection.templates)} templates")
785
+ """
786
+ self.templates.append(template)
787
+ self.update_modified_time()