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,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2024",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2024", "DOM", "DOM.Iterable"],
7
+ "strict": true,
8
+ "exactOptionalPropertyTypes": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "noImplicitOverride": true,
11
+ "noPropertyAccessFromIndexSignature": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "verbatimModuleSyntax": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true,
17
+ "outDir": "dist",
18
+ "rootDir": "src",
19
+ "skipLibCheck": true
20
+ },
21
+ "include": ["src"],
22
+ "exclude": ["node_modules", "dist"]
23
+ }
@@ -0,0 +1,30 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: {
5
+ // Plugins
6
+ "plugins/rating": "src/plugins/rating.ts",
7
+ "plugins/forced-choice": "src/plugins/forced-choice.ts",
8
+ "plugins/cloze-dropdown": "src/plugins/cloze-dropdown.ts",
9
+ // Library
10
+ "lib/list-distributor": "src/lib/list-distributor.ts",
11
+ "lib/randomizer": "src/lib/randomizer.ts",
12
+ // Slopit bundle (behavioral capture)
13
+ "slopit-bundle": "src/slopit/index.ts",
14
+ },
15
+ format: ["esm"],
16
+ dts: false, // browser code, no type exports needed
17
+ sourcemap: true,
18
+ clean: true,
19
+ target: "es2020",
20
+ splitting: false,
21
+ treeshake: true,
22
+ minify: false,
23
+ external: ["jspsych"],
24
+ // Generate IIFE bundles for browser usage
25
+ esbuildOptions(options) {
26
+ options.banner = {
27
+ js: "/* @bead/jspsych-deployment - Generated by bead */",
28
+ };
29
+ },
30
+ });
@@ -0,0 +1 @@
1
+ """UI components and styles for jsPsych experiments."""
@@ -0,0 +1,383 @@
1
+ """Python helpers for generating UI components for jsPsych experiments.
2
+
3
+ This module provides functions to generate UI component configurations
4
+ from bead models, inferring widget types from slot constraints.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from uuid import UUID
10
+
11
+ from bead.data.base import JsonValue
12
+ from bead.dsl import ast
13
+ from bead.dsl.parser import parse
14
+ from bead.items.item import UnfilledSlot
15
+ from bead.resources.constraints import Constraint
16
+
17
+
18
+ def create_rating_scale(
19
+ scale_min: int,
20
+ scale_max: int,
21
+ labels: dict[int, str] | None = None,
22
+ ) -> dict[str, int | dict[int, str]]:
23
+ """Generate jsPsych rating scale configuration.
24
+
25
+ Parameters
26
+ ----------
27
+ scale_min : int
28
+ Minimum value of the scale.
29
+ scale_max : int
30
+ Maximum value of the scale.
31
+ labels : dict[int, str] | None
32
+ Optional labels for specific scale points.
33
+
34
+ Returns
35
+ -------
36
+ dict[str, int | dict[int, str]]
37
+ Rating scale configuration dictionary.
38
+
39
+ Examples
40
+ --------
41
+ >>> config = create_rating_scale(
42
+ ... 1, 7, {1: "Strongly Disagree", 7: "Strongly Agree"}
43
+ ... )
44
+ >>> config["scale_min"]
45
+ 1
46
+ >>> config["scale_max"]
47
+ 7
48
+ """
49
+ return {
50
+ "scale_min": scale_min,
51
+ "scale_max": scale_max,
52
+ "scale_labels": labels or {},
53
+ }
54
+
55
+
56
+ def create_cloze_fields(
57
+ unfilled_slots: list[UnfilledSlot],
58
+ constraints: dict[UUID, Constraint],
59
+ lexicon: dict[UUID, str] | None = None,
60
+ ) -> list[dict[str, JsonValue]]:
61
+ """Generate cloze field configurations from slots and constraints.
62
+
63
+ Infers widget type from slot constraints:
64
+ - Constraint with "self.id in [...]" pattern: dropdown with specific items
65
+ - Other DSL constraints: text input with validation expression
66
+ - No constraints: free text input
67
+
68
+ Parameters
69
+ ----------
70
+ unfilled_slots : list[UnfilledSlot]
71
+ List of unfilled slots in the cloze task.
72
+ constraints : dict[UUID, Constraint]
73
+ Dictionary of constraints keyed by UUID (from slot.constraint_ids).
74
+ lexicon : dict[UUID, str] | None
75
+ Optional mapping from item UUIDs to surface forms for dropdown options.
76
+
77
+ Returns
78
+ -------
79
+ list[dict[str, JsonValue]]
80
+ List of field configuration dictionaries with keys:
81
+ - slot_name: Name of the slot
82
+ - position: Token position
83
+ - type: Widget type ("dropdown" or "text")
84
+ - options: List of allowed values (for dropdown)
85
+ - placeholder: Placeholder text
86
+ - dsl_expression: Constraint expression for validation (optional)
87
+ - dsl_context: Constraint context variables (optional)
88
+
89
+ Examples
90
+ --------
91
+ >>> from bead.items.item import UnfilledSlot
92
+ >>> from bead.resources.constraints import Constraint
93
+ >>> from uuid import uuid4
94
+ >>> constraint_id = uuid4()
95
+ >>> slot = UnfilledSlot(
96
+ ... slot_name="determiner",
97
+ ... position=0,
98
+ ... constraint_ids=[constraint_id]
99
+ ... )
100
+ >>> id1, id2 = uuid4(), uuid4()
101
+ >>> constraint = Constraint(
102
+ ... expression=f"self.id in [UUID('{id1}'), UUID('{id2}')]"
103
+ ... )
104
+ >>> lexicon = {id1: "the", id2: "a"}
105
+ >>> fields = create_cloze_fields([slot], {constraint_id: constraint}, lexicon)
106
+ >>> len(fields)
107
+ 1
108
+ >>> fields[0]["type"]
109
+ 'dropdown'
110
+ >>> len(fields[0]["options"])
111
+ 2
112
+ """
113
+ fields: list[dict[str, JsonValue]] = []
114
+
115
+ for slot in unfilled_slots:
116
+ # infer widget type
117
+ widget_type = infer_widget_type(slot.constraint_ids, constraints)
118
+
119
+ field_config: dict[str, JsonValue] = {
120
+ "slot_name": slot.slot_name,
121
+ "position": slot.position,
122
+ "type": widget_type,
123
+ "options": [],
124
+ "placeholder": slot.slot_name,
125
+ }
126
+
127
+ # analyze constraints to extract options and validation info
128
+ for constraint_id in slot.constraint_ids:
129
+ if constraint_id not in constraints:
130
+ continue
131
+
132
+ constraint = constraints[constraint_id]
133
+
134
+ # include constraint expression for client-side validation
135
+ field_config["dsl_expression"] = constraint.expression
136
+ if constraint.context:
137
+ # convert context to JsonValue compatible format
138
+ field_config["dsl_context"] = {
139
+ k: list(v) if isinstance(v, set) else v
140
+ for k, v in constraint.context.items()
141
+ }
142
+
143
+ # if dropdown, extract allowed item UUIDs and map to surface forms
144
+ if widget_type == "dropdown":
145
+ allowed_items = _extract_allowed_items_from_expression(
146
+ constraint.expression, constraint.context
147
+ )
148
+ if allowed_items:
149
+ # map UUIDs to surface forms if lexicon provided
150
+ if lexicon:
151
+ options = [
152
+ lexicon.get(item_id, str(item_id))
153
+ for item_id in allowed_items
154
+ ]
155
+ else:
156
+ # no lexicon; use UUID strings
157
+ options = [str(item_id) for item_id in allowed_items]
158
+
159
+ field_config["options"] = sorted(options)
160
+ field_config["item_ids"] = [
161
+ str(item_id) for item_id in allowed_items
162
+ ]
163
+
164
+ fields.append(field_config)
165
+
166
+ return fields
167
+
168
+
169
+ def create_forced_choice_config(
170
+ alternatives: list[str],
171
+ randomize_position: bool = True,
172
+ enable_keyboard: bool = True,
173
+ ) -> dict[str, list[str] | bool]:
174
+ """Generate forced choice configuration.
175
+
176
+ Parameters
177
+ ----------
178
+ alternatives : list[str]
179
+ List of alternative options to choose from.
180
+ randomize_position : bool
181
+ Whether to randomize left/right position.
182
+ enable_keyboard : bool
183
+ Whether to enable keyboard responses.
184
+
185
+ Returns
186
+ -------
187
+ dict[str, list[str] | bool]
188
+ Forced choice configuration dictionary.
189
+
190
+ Examples
191
+ --------
192
+ >>> config = create_forced_choice_config(["Option A", "Option B"])
193
+ >>> config["randomize_position"]
194
+ True
195
+ >>> len(config["alternatives"])
196
+ 2
197
+ """
198
+ return {
199
+ "alternatives": alternatives,
200
+ "randomize_position": randomize_position,
201
+ "enable_keyboard": enable_keyboard,
202
+ }
203
+
204
+
205
+ def _extract_allowed_items_from_expression(
206
+ expression: str,
207
+ context: dict[str, str | int | float | bool | list[str] | set[str] | set[UUID]]
208
+ | None = None,
209
+ ) -> set[UUID] | None:
210
+ """Extract allowed item UUIDs from a DSL constraint expression.
211
+
212
+ Detects patterns like:
213
+ - self.id in [uuid1, uuid2, ...] (inline list)
214
+ - self.id in allowed_items (context variable)
215
+ - str(self.id) in ['uuid-str1', 'uuid-str2', ...]
216
+
217
+ Parameters
218
+ ----------
219
+ expression : str
220
+ DSL constraint expression.
221
+ context : dict[str, str | int | float | bool | list[str] | set[str] \
222
+ | set[UUID]] | None
223
+ Constraint context variables.
224
+
225
+ Returns
226
+ -------
227
+ set[UUID] | None
228
+ Set of allowed UUIDs if pattern detected, None otherwise.
229
+
230
+ Examples
231
+ --------
232
+ >>> from uuid import UUID
233
+ >>> expr = "self.id in [UUID('...'), UUID('...')]"
234
+ >>> # Would return set of UUIDs if parseable
235
+ """
236
+ try:
237
+ node = parse(expression)
238
+ except Exception:
239
+ return None
240
+
241
+ # look for pattern: self.id in [...]
242
+ if isinstance(node, ast.BinaryOp) and node.operator == "in":
243
+ # check if left side is self.id or str(self.id)
244
+ left = node.left
245
+ is_self_id = False
246
+
247
+ if isinstance(left, ast.AttributeAccess):
248
+ # self.id
249
+ if (
250
+ isinstance(left.object, ast.Variable)
251
+ and left.object.name == "self"
252
+ and left.attribute == "id"
253
+ ):
254
+ is_self_id = True
255
+ elif isinstance(left, ast.FunctionCall):
256
+ # str(self.id)
257
+ if (
258
+ isinstance(left.function, ast.Variable)
259
+ and left.function.name == "str"
260
+ and len(left.arguments) == 1
261
+ ):
262
+ arg = left.arguments[0]
263
+ if isinstance(arg, ast.AttributeAccess):
264
+ if (
265
+ isinstance(arg.object, ast.Variable)
266
+ and arg.object.name == "self"
267
+ and arg.attribute == "id"
268
+ ):
269
+ is_self_id = True
270
+
271
+ if not is_self_id:
272
+ return None
273
+
274
+ # check right side; could be inline list or context variable
275
+ if isinstance(node.right, ast.ListLiteral):
276
+ # inline list: self.id in [UUID(...), ...]
277
+ uuids: set[UUID] = set()
278
+ for elem in node.right.elements:
279
+ # handle UUID(...) function calls
280
+ if isinstance(elem, ast.FunctionCall):
281
+ if (
282
+ isinstance(elem.function, ast.Variable)
283
+ and elem.function.name == "UUID"
284
+ and len(elem.arguments) == 1
285
+ ):
286
+ arg = elem.arguments[0]
287
+ if isinstance(arg, ast.Literal) and isinstance(arg.value, str):
288
+ try:
289
+ uuids.add(UUID(arg.value))
290
+ except (ValueError, AttributeError):
291
+ pass
292
+ # handle string literals (for str(self.id) pattern)
293
+ elif isinstance(elem, ast.Literal) and isinstance(elem.value, str):
294
+ try:
295
+ uuids.add(UUID(elem.value))
296
+ except (ValueError, AttributeError):
297
+ pass
298
+
299
+ if uuids:
300
+ return uuids
301
+
302
+ elif isinstance(node.right, ast.Variable) and context:
303
+ # context variable: self.id in allowed_items
304
+ var_name = node.right.name
305
+ if var_name in context:
306
+ value = context[var_name]
307
+ # check if it's a set or list of UUIDs
308
+ if isinstance(value, set | list):
309
+ uuids = set()
310
+ for item in value:
311
+ if isinstance(item, UUID):
312
+ uuids.add(item)
313
+ elif isinstance(item, str):
314
+ try:
315
+ uuids.add(UUID(item))
316
+ except (ValueError, AttributeError):
317
+ pass
318
+ if uuids:
319
+ return uuids
320
+
321
+ return None
322
+
323
+
324
+ def infer_widget_type(
325
+ constraint_ids: list[UUID],
326
+ constraints: dict[UUID, Constraint],
327
+ ) -> str:
328
+ """Infer UI widget type from slot constraints.
329
+
330
+ Analyzes the constraint DSL expressions to determine the most appropriate
331
+ UI widget for collecting user input.
332
+
333
+ Widget type inference logic:
334
+ - Constraint with pattern "self.id in [uuid1, uuid2, ...]": "dropdown"
335
+ - Other DSL expressions: "text"
336
+ - No constraints: "text"
337
+
338
+ Parameters
339
+ ----------
340
+ constraint_ids : list[UUID]
341
+ List of constraint IDs for the slot.
342
+ constraints : dict[UUID, Constraint]
343
+ Dictionary of constraint objects keyed by UUID.
344
+
345
+ Returns
346
+ -------
347
+ str
348
+ Widget type: "dropdown" or "text".
349
+
350
+ Examples
351
+ --------
352
+ >>> from bead.resources.constraints import Constraint
353
+ >>> from uuid import uuid4
354
+ >>> constraint_id = uuid4()
355
+ >>> id1, id2 = uuid4(), uuid4()
356
+ >>> constraint = Constraint(expression=f"self.id in [UUID('{id1}'), UUID('{id2}')]")
357
+ >>> widget = infer_widget_type([constraint_id], {constraint_id: constraint})
358
+ >>> widget
359
+ 'dropdown'
360
+ >>> widget2 = infer_widget_type([], {})
361
+ >>> widget2
362
+ 'text'
363
+ """
364
+ if not constraint_ids:
365
+ return "text"
366
+
367
+ # check each constraint for extensional pattern
368
+ for constraint_id in constraint_ids:
369
+ if constraint_id not in constraints:
370
+ continue
371
+
372
+ constraint = constraints[constraint_id]
373
+
374
+ # try to extract allowed items from expression (with context)
375
+ allowed_items = _extract_allowed_items_from_expression(
376
+ constraint.expression, constraint.context
377
+ )
378
+ if allowed_items:
379
+ # found a fixed set of allowed items; use dropdown
380
+ return "dropdown"
381
+
382
+ # default to text input
383
+ return "text"