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
bead/items/item.py ADDED
@@ -0,0 +1,396 @@
1
+ """Data models for constructed experimental items."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import UUID
6
+
7
+ from pydantic import Field, field_validator
8
+
9
+ from bead.data.base import BeadBaseModel
10
+
11
+ # Type aliases for JSON-serializable metadata values
12
+ type MetadataValue = (
13
+ str | int | float | bool | None | dict[str, MetadataValue] | list[MetadataValue]
14
+ )
15
+
16
+
17
+ # Factory functions for default values with explicit types
18
+ def _empty_uuid_list() -> list[UUID]:
19
+ """Return empty UUID list."""
20
+ return []
21
+
22
+
23
+ def _empty_unfilled_slot_list() -> list[UnfilledSlot]:
24
+ """Return empty UnfilledSlot list."""
25
+ return []
26
+
27
+
28
+ def _empty_model_output_list() -> list[ModelOutput]:
29
+ """Return empty ModelOutput list."""
30
+ return []
31
+
32
+
33
+ def _empty_item_list() -> list[Item]:
34
+ """Return empty Item list."""
35
+ return []
36
+
37
+
38
+ def _empty_str_dict() -> dict[str, str]:
39
+ """Return empty string-to-string dict."""
40
+ return {}
41
+
42
+
43
+ def _empty_uuid_bool_dict() -> dict[UUID, bool]:
44
+ """Return empty UUID-to-bool dict."""
45
+ return {}
46
+
47
+
48
+ def _empty_metadata_dict() -> dict[str, MetadataValue]:
49
+ """Return empty metadata dict."""
50
+ return {}
51
+
52
+
53
+ def _empty_str_list() -> list[str]:
54
+ """Return empty string list."""
55
+ return []
56
+
57
+
58
+ class UnfilledSlot(BeadBaseModel):
59
+ """An unfilled slot in a cloze task item.
60
+
61
+ Represents a slot in a partially filled template where the participant
62
+ must provide a response. The UI widget for collecting the response is
63
+ inferred from the slot's constraints at deployment time.
64
+
65
+ Attributes
66
+ ----------
67
+ slot_name : str
68
+ Name of the unfilled template slot.
69
+ position : int
70
+ Token index position in the rendered text.
71
+ constraint_ids : list[UUID]
72
+ UUIDs of constraints that apply to this slot.
73
+
74
+ Examples
75
+ --------
76
+ >>> from uuid import UUID
77
+ >>> # Extensional constraint slot (will render as dropdown)
78
+ >>> UnfilledSlot(
79
+ ... slot_name="determiner",
80
+ ... position=0,
81
+ ... constraint_ids=[UUID("12345678-1234-5678-1234-567812345678")]
82
+ ... )
83
+ >>> # Unconstrained slot (will render as text input)
84
+ >>> UnfilledSlot(
85
+ ... slot_name="adjective",
86
+ ... position=2,
87
+ ... constraint_ids=[]
88
+ ... )
89
+ """
90
+
91
+ slot_name: str = Field(..., description="Template slot name")
92
+ position: int = Field(..., description="Token position in rendered text")
93
+ constraint_ids: list[UUID] = Field(
94
+ default_factory=_empty_uuid_list, description="Constraint UUIDs for this slot"
95
+ )
96
+
97
+ @field_validator("slot_name")
98
+ @classmethod
99
+ def validate_slot_name(cls, v: str) -> str:
100
+ """Validate slot name is not empty.
101
+
102
+ Parameters
103
+ ----------
104
+ v : str
105
+ Slot name to validate.
106
+
107
+ Returns
108
+ -------
109
+ str
110
+ Validated slot name.
111
+
112
+ Raises
113
+ ------
114
+ ValueError
115
+ If slot name is empty or contains only whitespace.
116
+ """
117
+ if not v or not v.strip():
118
+ raise ValueError("Slot name cannot be empty")
119
+ return v.strip()
120
+
121
+
122
+ class ModelOutput(BeadBaseModel):
123
+ """Output from a model computation.
124
+
125
+ Attributes
126
+ ----------
127
+ model_name : str
128
+ Name/identifier of the model.
129
+ model_version : str
130
+ Version of the model.
131
+ operation : str
132
+ Operation performed (e.g., "log_probability", "nli", "embedding").
133
+ inputs : dict[str, MetadataValue]
134
+ Inputs to the model.
135
+ output : MetadataValue
136
+ Model output.
137
+ cache_key : str
138
+ Cache key for this computation.
139
+ computation_metadata : dict[str, MetadataValue]
140
+ Metadata about the computation (timestamp, device, etc.).
141
+
142
+ Examples
143
+ --------
144
+ >>> output = ModelOutput(
145
+ ... model_name="gpt2",
146
+ ... model_version="latest",
147
+ ... operation="log_probability",
148
+ ... inputs={"text": "The cat broke the vase"},
149
+ ... output=-12.4,
150
+ ... cache_key="abc123..."
151
+ ... )
152
+ """
153
+
154
+ model_name: str = Field(..., description="Model identifier")
155
+ model_version: str = Field(..., description="Model version")
156
+ operation: str = Field(..., description="Operation type")
157
+ inputs: dict[str, MetadataValue] = Field(..., description="Model inputs")
158
+ output: MetadataValue = Field(..., description="Model output")
159
+ cache_key: str = Field(..., description="Cache key")
160
+ computation_metadata: dict[str, MetadataValue] = Field(
161
+ default_factory=_empty_metadata_dict, description="Computation metadata"
162
+ )
163
+
164
+ @field_validator("model_name", "model_version", "operation", "cache_key")
165
+ @classmethod
166
+ def validate_non_empty_strings(cls, v: str) -> str:
167
+ """Validate required string fields are not empty.
168
+
169
+ Parameters
170
+ ----------
171
+ v : str
172
+ String value to validate.
173
+
174
+ Returns
175
+ -------
176
+ str
177
+ Validated string.
178
+
179
+ Raises
180
+ ------
181
+ ValueError
182
+ If string is empty or contains only whitespace.
183
+ """
184
+ if not v or not v.strip():
185
+ raise ValueError("Field cannot be empty")
186
+ return v.strip()
187
+
188
+
189
+ class Item(BeadBaseModel):
190
+ """A constructed experimental item.
191
+
192
+ Items are discrete stimuli presented to participants or models
193
+ for judgment collection. They are constructed from item templates
194
+ and filled templates.
195
+
196
+ Attributes
197
+ ----------
198
+ item_template_id : UUID
199
+ UUID of the item template this was constructed from.
200
+ filled_template_refs : list[UUID]
201
+ UUIDs of filled templates used in this item.
202
+ rendered_elements : dict[str, str]
203
+ Rendered text for each element (by element_name).
204
+ options : list[str]
205
+ Choice options for forced_choice/multi_select tasks. Each string
206
+ is one option text. Order matters (first option is displayed first).
207
+ unfilled_slots : list[UnfilledSlot]
208
+ Unfilled slots for cloze tasks (UI widgets inferred from constraints).
209
+ model_outputs : list[ModelOutput]
210
+ All model computations for this item.
211
+ constraint_satisfaction : dict[UUID, bool]
212
+ Constraint UUIDs mapped to satisfaction status.
213
+ item_metadata : dict[str, MetadataValue]
214
+ Additional metadata for this item.
215
+
216
+ Examples
217
+ --------
218
+ >>> # Simple item
219
+ >>> item = Item(
220
+ ... item_template_id=UUID("..."),
221
+ ... filled_template_refs=[UUID("...")],
222
+ ... rendered_elements={"sentence": "The cat broke the vase"}
223
+ ... )
224
+ >>> # Forced-choice item with options
225
+ >>> fc_item = Item(
226
+ ... item_template_id=UUID("..."),
227
+ ... options=["The cat sat on the mat.", "The cats sat on the mat."],
228
+ ... item_metadata={"n_options": 2}
229
+ ... )
230
+ >>> # Cloze item with unfilled slots
231
+ >>> cloze_item = Item(
232
+ ... item_template_id=UUID("..."),
233
+ ... rendered_elements={"sentence": "The ___ cat ___ the ___"},
234
+ ... unfilled_slots=[
235
+ ... UnfilledSlot(slot_name="determiner", position=0, constraint_ids=[...]),
236
+ ... UnfilledSlot(slot_name="verb", position=2, constraint_ids=[...])
237
+ ... ]
238
+ ... )
239
+ """
240
+
241
+ item_template_id: UUID = Field(..., description="ItemTemplate ID")
242
+ filled_template_refs: list[UUID] = Field(
243
+ default_factory=_empty_uuid_list, description="Filled template UUIDs"
244
+ )
245
+ rendered_elements: dict[str, str] = Field(
246
+ default_factory=_empty_str_dict, description="Rendered element text"
247
+ )
248
+ options: list[str] = Field(
249
+ default_factory=_empty_str_list,
250
+ description="Choice options for forced_choice/multi_select tasks",
251
+ )
252
+ unfilled_slots: list[UnfilledSlot] = Field(
253
+ default_factory=_empty_unfilled_slot_list,
254
+ description="Unfilled slots for cloze tasks",
255
+ )
256
+ model_outputs: list[ModelOutput] = Field(
257
+ default_factory=_empty_model_output_list, description="Model computations"
258
+ )
259
+ constraint_satisfaction: dict[UUID, bool] = Field(
260
+ default_factory=_empty_uuid_bool_dict,
261
+ description="Constraint satisfaction status",
262
+ )
263
+ item_metadata: dict[str, MetadataValue] = Field(
264
+ default_factory=_empty_metadata_dict, description="Additional metadata"
265
+ )
266
+
267
+ def get_model_output(
268
+ self,
269
+ model_name: str,
270
+ operation: str,
271
+ inputs: dict[str, MetadataValue] | None = None,
272
+ ) -> ModelOutput | None:
273
+ """Get a specific model output.
274
+
275
+ Parameters
276
+ ----------
277
+ model_name : str
278
+ Name of the model.
279
+ operation : str
280
+ Operation type.
281
+ inputs : dict[str, MetadataValue] | None
282
+ Optional input filter.
283
+
284
+ Returns
285
+ -------
286
+ ModelOutput | None
287
+ The model output if found, None otherwise.
288
+
289
+ Examples
290
+ --------
291
+ >>> output = item.get_model_output("gpt2", "log_probability")
292
+ >>> if output:
293
+ ... print(f"Log prob: {output.output}")
294
+ """
295
+ for output in self.model_outputs:
296
+ if output.model_name == model_name and output.operation == operation:
297
+ if inputs is None or output.inputs == inputs:
298
+ return output
299
+ return None
300
+
301
+ def add_model_output(self, output: ModelOutput) -> None:
302
+ """Add a model output to this item.
303
+
304
+ Parameters
305
+ ----------
306
+ output : ModelOutput
307
+ Model output to add.
308
+
309
+ Examples
310
+ --------
311
+ >>> item.add_model_output(my_output)
312
+ >>> print(f"Item now has {len(item.model_outputs)} model outputs")
313
+ """
314
+ self.model_outputs.append(output)
315
+ self.update_modified_time()
316
+
317
+
318
+ class ItemCollection(BeadBaseModel):
319
+ """A collection of constructed items.
320
+
321
+ Attributes
322
+ ----------
323
+ name : str
324
+ Name of this collection.
325
+ source_template_collection_id : UUID
326
+ UUID of the source item template collection.
327
+ source_filled_collection_id : UUID
328
+ UUID of the source filled template collection.
329
+ items : list[Item]
330
+ The constructed items.
331
+ construction_stats : dict[str, int]
332
+ Statistics about item construction.
333
+
334
+ Examples
335
+ --------
336
+ >>> collection = ItemCollection(
337
+ ... name="acceptability_items",
338
+ ... source_template_collection_id=UUID("..."),
339
+ ... source_filled_collection_id=UUID("...")
340
+ ... )
341
+ >>> collection.add_item(item)
342
+ """
343
+
344
+ name: str = Field(..., description="Collection name")
345
+ source_template_collection_id: UUID = Field(
346
+ ..., description="Source template collection UUID"
347
+ )
348
+ source_filled_collection_id: UUID = Field(
349
+ ..., description="Source filled collection UUID"
350
+ )
351
+ items: list[Item] = Field(
352
+ default_factory=_empty_item_list, description="Constructed items"
353
+ )
354
+ construction_stats: dict[str, int] = Field(
355
+ default_factory=dict, description="Construction statistics"
356
+ )
357
+
358
+ @field_validator("name")
359
+ @classmethod
360
+ def validate_name(cls, v: str) -> str:
361
+ """Validate collection name is not empty.
362
+
363
+ Parameters
364
+ ----------
365
+ v : str
366
+ Collection name to validate.
367
+
368
+ Returns
369
+ -------
370
+ str
371
+ Validated collection name.
372
+
373
+ Raises
374
+ ------
375
+ ValueError
376
+ If name is empty or contains only whitespace.
377
+ """
378
+ if not v or not v.strip():
379
+ raise ValueError("Collection name cannot be empty")
380
+ return v.strip()
381
+
382
+ def add_item(self, item: Item) -> None:
383
+ """Add an item to the collection.
384
+
385
+ Parameters
386
+ ----------
387
+ item : Item
388
+ Item to add.
389
+
390
+ Examples
391
+ --------
392
+ >>> collection.add_item(my_item)
393
+ >>> print(f"Collection now has {len(collection.items)} items")
394
+ """
395
+ self.items.append(item)
396
+ self.update_modified_time()