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,413 @@
1
+ """Utilities for creating N-AFC (forced-choice) experimental items.
2
+
3
+ This module provides language-agnostic utilities for creating forced-choice
4
+ items where participants select from N alternatives (2AFC, 3AFC, 4AFC, etc.).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import defaultdict
10
+ from collections.abc import Callable
11
+ from itertools import combinations, product
12
+ from typing import Any
13
+ from uuid import UUID, uuid4
14
+
15
+ from bead.items.item import Item, MetadataValue
16
+
17
+
18
+ def create_forced_choice_item(
19
+ *options: str,
20
+ item_template_id: UUID | None = None,
21
+ metadata: dict[str, MetadataValue] | None = None,
22
+ ) -> Item:
23
+ """Create an N-AFC (forced-choice) item from N text options.
24
+
25
+ Parameters
26
+ ----------
27
+ *options : str
28
+ Text for each option (2 or more required).
29
+ item_template_id : UUID | None
30
+ Template ID for the item. If None, generates new UUID.
31
+ metadata : dict[str, MetadataValue] | None
32
+ Additional metadata for item_metadata field.
33
+
34
+ Returns
35
+ -------
36
+ Item
37
+ Forced-choice item with options stored in the options field.
38
+
39
+ Raises
40
+ ------
41
+ ValueError
42
+ If fewer than 2 options provided.
43
+
44
+ Examples
45
+ --------
46
+ >>> item = create_forced_choice_item(
47
+ ... "The cat sat on the mat.",
48
+ ... "The cats sat on the mat.",
49
+ ... metadata={"contrast": "number"}
50
+ ... )
51
+ >>> item.options[0]
52
+ 'The cat sat on the mat.'
53
+ >>> item.options[1]
54
+ 'The cats sat on the mat.'
55
+
56
+ >>> # 4AFC item
57
+ >>> item = create_forced_choice_item(
58
+ ... "Option A text",
59
+ ... "Option B text",
60
+ ... "Option C text",
61
+ ... "Option D text"
62
+ ... )
63
+ >>> len(item.options)
64
+ 4
65
+ """
66
+ if len(options) < 2:
67
+ raise ValueError("At least 2 options required for forced-choice item")
68
+
69
+ if item_template_id is None:
70
+ item_template_id = uuid4()
71
+
72
+ # Build item metadata with n_options (consistent with other task types)
73
+ item_metadata: dict[str, MetadataValue] = {
74
+ "n_options": len(options),
75
+ }
76
+ if metadata:
77
+ item_metadata.update(metadata)
78
+
79
+ return Item(
80
+ item_template_id=item_template_id,
81
+ options=list(options),
82
+ item_metadata=item_metadata,
83
+ )
84
+
85
+
86
+ def create_forced_choice_items_from_groups(
87
+ items: list[Item],
88
+ group_by: Callable[[Item], Any],
89
+ n_alternatives: int = 2,
90
+ *,
91
+ extract_text: Callable[[Item], str] | None = None,
92
+ include_group_metadata: bool = True,
93
+ item_template_id: UUID | None = None,
94
+ ) -> list[Item]:
95
+ """Create forced-choice items by grouping source items.
96
+
97
+ Groups items by a property, then creates all N-way combinations within
98
+ each group as forced-choice items.
99
+
100
+ Parameters
101
+ ----------
102
+ items : list[Item]
103
+ Source items to group and combine.
104
+ group_by : Callable[[Item], Any]
105
+ Function to extract grouping key from items.
106
+ n_alternatives : int
107
+ Number of alternatives per forced-choice item (default: 2 for 2AFC).
108
+ extract_text : Callable[[Item], str] | None
109
+ Function to extract text from item. If None, tries common keys
110
+ ("text", "sentence", "content") from rendered_elements.
111
+ include_group_metadata : bool
112
+ Whether to include group key in item metadata.
113
+ item_template_id : UUID | None
114
+ Template ID for all created items. If None, generates one per item.
115
+
116
+ Returns
117
+ -------
118
+ list[Item]
119
+ Forced-choice items created from groupings.
120
+
121
+ Examples
122
+ --------
123
+ Create 2AFC items with same verb (same-verb minimal pairs):
124
+ >>> items = [
125
+ ... Item(
126
+ ... item_template_id=uuid4(),
127
+ ... rendered_elements={"text": "She walks."},
128
+ ... item_metadata={"verb": "walk", "frame": "intransitive"}
129
+ ... ),
130
+ ... Item(
131
+ ... item_template_id=uuid4(),
132
+ ... rendered_elements={"text": "She walks the dog."},
133
+ ... item_metadata={"verb": "walk", "frame": "transitive"}
134
+ ... )
135
+ ... ]
136
+ >>> fc_items = create_forced_choice_items_from_groups(
137
+ ... items,
138
+ ... group_by=lambda item: item.item_metadata["verb"],
139
+ ... n_alternatives=2
140
+ ... )
141
+ >>> len(fc_items)
142
+ 1
143
+ >>> fc_items[0].rendered_elements["option_a"]
144
+ 'She walks.'
145
+
146
+ Create 3AFC items grouped by template:
147
+ >>> fc_items = create_forced_choice_items_from_groups(
148
+ ... items,
149
+ ... group_by=lambda item: item.item_template_id,
150
+ ... n_alternatives=3
151
+ ... ) # doctest: +SKIP
152
+ """
153
+ # Group items
154
+ groups: dict[Any, list[Item]] = defaultdict(list)
155
+ for item in items:
156
+ group_key = group_by(item)
157
+ groups[group_key].append(item)
158
+
159
+ # Create forced-choice items from each group
160
+ fc_items: list[Item] = []
161
+
162
+ for group_key, group_items in groups.items():
163
+ # Generate all N-way combinations within group
164
+ for combo in combinations(group_items, n_alternatives):
165
+ # Extract text from each item
166
+ texts: list[str] = []
167
+ for item in combo:
168
+ if extract_text:
169
+ text: str = extract_text(item)
170
+ else:
171
+ text = _extract_text_from_item(item)
172
+ texts.append(text)
173
+
174
+ # Build metadata
175
+ metadata: dict[str, MetadataValue] = {}
176
+ if include_group_metadata:
177
+ metadata["group_key"] = str(group_key)
178
+
179
+ # Include source item IDs
180
+ for i, item in enumerate(combo):
181
+ metadata[f"source_item_{i}_id"] = str(item.id)
182
+
183
+ # Create forced-choice item
184
+ fc_item = create_forced_choice_item(
185
+ *texts, item_template_id=item_template_id, metadata=metadata
186
+ )
187
+ fc_items.append(fc_item)
188
+
189
+ return fc_items
190
+
191
+
192
+ def create_forced_choice_items_cross_product(
193
+ group1_items: list[Item],
194
+ group2_items: list[Item],
195
+ n_from_group1: int = 1,
196
+ n_from_group2: int = 1,
197
+ *,
198
+ extract_text: Callable[[Item], str] | None = None,
199
+ item_template_id: UUID | None = None,
200
+ metadata_fn: (
201
+ Callable[[list[Item], list[Item]], dict[str, MetadataValue]] | None
202
+ ) = None,
203
+ ) -> list[Item]:
204
+ """Create forced-choice items from cross-product of two groups.
205
+
206
+ Combines n items from group1 with n items from group2 to create
207
+ (n_from_group1 + n_from_group2)-AFC items.
208
+
209
+ Parameters
210
+ ----------
211
+ group1_items : list[Item]
212
+ Items in first group.
213
+ group2_items : list[Item]
214
+ Items in second group.
215
+ n_from_group1 : int
216
+ Number of items to select from group1 per combination (default: 1).
217
+ n_from_group2 : int
218
+ Number of items to select from group2 per combination (default: 1).
219
+ extract_text : Callable[[Item], str] | None
220
+ Function to extract text from items.
221
+ item_template_id : UUID | None
222
+ Template ID for all created items.
223
+ metadata_fn : Callable[[list[Item], list[Item]], dict[str, MetadataValue]] | None
224
+ Function to generate metadata from (group1_items_used, group2_items_used).
225
+
226
+ Returns
227
+ -------
228
+ list[Item]
229
+ Forced-choice items from cross-product.
230
+
231
+ Examples
232
+ --------
233
+ Create 2AFC items pairing grammatical with ungrammatical:
234
+ >>> grammatical = [
235
+ ... Item(
236
+ ... uuid4(),
237
+ ... rendered_elements={"text": "She walks."},
238
+ ... item_metadata={"grammatical": True}
239
+ ... )
240
+ ... ]
241
+ >>> ungrammatical = [
242
+ ... Item(
243
+ ... uuid4(),
244
+ ... rendered_elements={"text": "She walk."},
245
+ ... item_metadata={"grammatical": False}
246
+ ... )
247
+ ... ]
248
+ >>> fc_items = create_forced_choice_items_cross_product(
249
+ ... grammatical,
250
+ ... ungrammatical,
251
+ ... n_from_group1=1,
252
+ ... n_from_group2=1
253
+ ... )
254
+ >>> len(fc_items)
255
+ 1
256
+ """
257
+ # Generate combinations from each group
258
+ group1_combos = list(combinations(group1_items, n_from_group1))
259
+ group2_combos = list(combinations(group2_items, n_from_group2))
260
+
261
+ fc_items: list[Item] = []
262
+
263
+ # Cross-product of combinations
264
+ for combo1, combo2 in product(group1_combos, group2_combos):
265
+ all_items = list(combo1) + list(combo2)
266
+
267
+ # Extract texts
268
+ texts: list[str] = []
269
+ for item in all_items:
270
+ if extract_text:
271
+ text: str = extract_text(item)
272
+ else:
273
+ text = _extract_text_from_item(item)
274
+ texts.append(text)
275
+
276
+ # Build metadata
277
+ metadata: dict[str, MetadataValue]
278
+ if metadata_fn:
279
+ metadata = metadata_fn(list(combo1), list(combo2))
280
+ else:
281
+ metadata = {
282
+ "source_group1_ids": [str(item.id) for item in combo1],
283
+ "source_group2_ids": [str(item.id) for item in combo2],
284
+ }
285
+
286
+ # Create forced-choice item
287
+ fc_item = create_forced_choice_item(
288
+ *texts, item_template_id=item_template_id, metadata=metadata
289
+ )
290
+ fc_items.append(fc_item)
291
+
292
+ return fc_items
293
+
294
+
295
+ def create_filtered_forced_choice_items(
296
+ items: list[Item],
297
+ group_by: Callable[[Item], Any],
298
+ n_alternatives: int = 2,
299
+ *,
300
+ item_filter: Callable[[Item], bool] | None = None,
301
+ group_filter: Callable[[Any, list[Item]], bool] | None = None,
302
+ combination_filter: Callable[[tuple[Item, ...]], bool] | None = None,
303
+ extract_text: Callable[[Item], str] | None = None,
304
+ item_template_id: UUID | None = None,
305
+ ) -> list[Item]:
306
+ """Create forced-choice items with multi-level filtering.
307
+
308
+ Parameters
309
+ ----------
310
+ items : list[Item]
311
+ Source items.
312
+ group_by : Callable[[Item], Any]
313
+ Grouping function.
314
+ n_alternatives : int
315
+ Number of alternatives per item.
316
+ item_filter : Callable[[Item], bool] | None
317
+ Filter individual items before grouping.
318
+ group_filter : Callable[[Any, list[Item]], bool] | None
319
+ Filter groups (receives group_key and group_items).
320
+ combination_filter : Callable[[tuple[Item, ...]], bool] | None
321
+ Filter specific combinations.
322
+ extract_text : Callable[[Item], str] | None
323
+ Text extraction function.
324
+ item_template_id : UUID | None
325
+ Template ID for created items.
326
+
327
+ Returns
328
+ -------
329
+ list[Item]
330
+ Filtered forced-choice items.
331
+
332
+ Examples
333
+ --------
334
+ >>> fc_items = create_filtered_forced_choice_items(
335
+ ... items,
336
+ ... group_by=lambda i: i.item_metadata["verb"],
337
+ ... n_alternatives=2,
338
+ ... item_filter=lambda i: i.item_metadata.get("valid", True),
339
+ ... group_filter=lambda key, items: len(items) >= 2,
340
+ ... combination_filter=lambda combo: combo[0].id != combo[1].id
341
+ ... ) # doctest: +SKIP
342
+ """
343
+ # Filter items
344
+ filtered_items = items
345
+ if item_filter:
346
+ filtered_items = [item for item in items if item_filter(item)]
347
+
348
+ # Group items
349
+ groups: dict[Any, list[Item]] = defaultdict(list)
350
+ for item in filtered_items:
351
+ group_key = group_by(item)
352
+ groups[group_key].append(item)
353
+
354
+ # Filter groups
355
+ if group_filter:
356
+ groups = {k: v for k, v in groups.items() if group_filter(k, v)}
357
+
358
+ # Create combinations
359
+ fc_items: list[Item] = []
360
+ for group_key, group_items in groups.items():
361
+ for combo in combinations(group_items, n_alternatives):
362
+ # Filter combination
363
+ if combination_filter and not combination_filter(combo):
364
+ continue
365
+
366
+ # Extract texts
367
+ texts: list[str] = []
368
+ for item in combo:
369
+ if extract_text:
370
+ text: str = extract_text(item)
371
+ else:
372
+ text = _extract_text_from_item(item)
373
+ texts.append(text)
374
+
375
+ # Create item
376
+ metadata: dict[str, MetadataValue] = {
377
+ "group_key": str(group_key),
378
+ "source_item_ids": [str(item.id) for item in combo],
379
+ }
380
+
381
+ fc_item = create_forced_choice_item(
382
+ *texts, item_template_id=item_template_id, metadata=metadata
383
+ )
384
+ fc_items.append(fc_item)
385
+
386
+ return fc_items
387
+
388
+
389
+ def _extract_text_from_item(item: Item) -> str:
390
+ """Extract text from item's rendered_elements.
391
+
392
+ Tries common keys: "text", "sentence", "content".
393
+ Falls back to string representation if not found.
394
+
395
+ Parameters
396
+ ----------
397
+ item : Item
398
+ Item to extract text from.
399
+
400
+ Returns
401
+ -------
402
+ str
403
+ Extracted text.
404
+ """
405
+ for key in ["text", "sentence", "content"]:
406
+ if key in item.rendered_elements:
407
+ return item.rendered_elements[key]
408
+
409
+ # Fallback: use first value or string representation
410
+ if item.rendered_elements:
411
+ return next(iter(item.rendered_elements.values()))
412
+
413
+ return str(item.rendered_elements)