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,252 @@
1
+ /**
2
+ * bead-cloze-multi plugin
3
+ *
4
+ * jsPsych plugin for fill-in-the-blank tasks with multiple gaps.
5
+ *
6
+ * Features:
7
+ * - Multiple gaps in text
8
+ * - Dropdown (extensional constraints)
9
+ * - Text input (intensional constraints or free text)
10
+ * - Mixed field types
11
+ * - Per-gap response time tracking
12
+ * - Material Design form controls
13
+ * - Preserves all item and template metadata
14
+ *
15
+ * @author Bead Project
16
+ * @version 0.1.0
17
+ */
18
+
19
+ import type { JsPsych, JsPsychPlugin, PluginInfo } from "../types/jspsych.js";
20
+
21
+ /** Field type for cloze fields */
22
+ type ClozeFieldType = "dropdown" | "text";
23
+
24
+ /** Unfilled slot from bead metadata */
25
+ interface UnfilledSlot {
26
+ slot_name: string;
27
+ position: number;
28
+ constraint_ids: string[];
29
+ }
30
+
31
+ /** Field configuration for cloze task */
32
+ export interface ClozeFieldConfig {
33
+ slot_name?: string;
34
+ position?: number;
35
+ type: ClozeFieldType;
36
+ options?: string[];
37
+ placeholder?: string;
38
+ }
39
+
40
+ /** Bead item/template metadata */
41
+ interface BeadMetadata {
42
+ unfilled_slots?: UnfilledSlot[];
43
+ [key: string]: unknown;
44
+ }
45
+
46
+ /** Cloze trial parameters */
47
+ export interface ClozeTrialParams {
48
+ /** The text with gaps (use %% or {{slot_name}} for gaps) */
49
+ text: string | null;
50
+ /** Field configurations (auto-generated from unfilled_slots) */
51
+ fields: ClozeFieldConfig[];
52
+ /** Whether to require all fields to be filled */
53
+ require_all: boolean;
54
+ /** Text for the continue button */
55
+ button_label: string;
56
+ /** Complete item and template metadata */
57
+ metadata: BeadMetadata;
58
+ }
59
+
60
+ /** Per-field responses */
61
+ type ClozeResponses = Record<string, string>;
62
+
63
+ /** Per-field response times */
64
+ type ClozeResponseTimes = Record<string, number>;
65
+
66
+ /** Per-field start times */
67
+ type FieldStartTimes = Record<string, number>;
68
+
69
+ /** Plugin info constant */
70
+ const info: PluginInfo = {
71
+ name: "bead-cloze-multi",
72
+ parameters: {
73
+ text: {
74
+ type: 8, // ParameterType.HTML_STRING
75
+ default: null,
76
+ },
77
+ fields: {
78
+ type: 13, // ParameterType.COMPLEX
79
+ default: [],
80
+ array: true,
81
+ },
82
+ require_all: {
83
+ type: 0, // ParameterType.BOOL
84
+ default: true,
85
+ },
86
+ button_label: {
87
+ type: 1, // ParameterType.STRING
88
+ default: "Continue",
89
+ },
90
+ metadata: {
91
+ type: 12, // ParameterType.OBJECT
92
+ default: {},
93
+ },
94
+ },
95
+ };
96
+
97
+ /**
98
+ * BeadClozeMultiPlugin - jsPsych plugin for fill-in-the-blank tasks
99
+ */
100
+ class BeadClozeMultiPlugin implements JsPsychPlugin<typeof info, ClozeTrialParams> {
101
+ static info = info;
102
+
103
+ private jsPsych: JsPsych;
104
+
105
+ constructor(jsPsych: JsPsych) {
106
+ this.jsPsych = jsPsych;
107
+ }
108
+
109
+ trial(display_element: HTMLElement, trial: ClozeTrialParams): void {
110
+ const responses: ClozeResponses = {};
111
+ const response_times: ClozeResponseTimes = {};
112
+ const field_start_times: FieldStartTimes = {};
113
+
114
+ const start_time = performance.now();
115
+
116
+ // Auto-generate fields from metadata if not provided
117
+ if (trial.fields.length === 0 && trial.metadata.unfilled_slots) {
118
+ trial.fields = trial.metadata.unfilled_slots.map((slot) => ({
119
+ slot_name: slot.slot_name,
120
+ position: slot.position,
121
+ type: (slot.constraint_ids.length > 0 ? "dropdown" : "text") as ClozeFieldType,
122
+ options: [], // Would be populated from constraints in real implementation
123
+ placeholder: slot.slot_name,
124
+ }));
125
+ }
126
+
127
+ // Generate HTML
128
+ let html = '<div class="bead-cloze-container">';
129
+
130
+ if (trial.text) {
131
+ // Replace gaps with input fields
132
+ let processed_text = trial.text;
133
+ trial.fields.forEach((field, index) => {
134
+ const field_id = `bead-cloze-field-${index}`;
135
+ let field_html: string;
136
+
137
+ if (field.type === "dropdown" && field.options && field.options.length > 0) {
138
+ // Dropdown field
139
+ const optionsHtml = field.options
140
+ .map((opt) => `<option value="${opt}">${opt}</option>`)
141
+ .join("");
142
+ field_html = `
143
+ <select class="bead-dropdown bead-cloze-field" id="${field_id}" data-field="${index}">
144
+ <option value="">Select...</option>
145
+ ${optionsHtml}
146
+ </select>
147
+ `;
148
+ } else {
149
+ // Text input field
150
+ field_html = `
151
+ <input type="text"
152
+ class="bead-text-field bead-cloze-field"
153
+ id="${field_id}"
154
+ data-field="${index}"
155
+ placeholder="${field.placeholder ?? ""}" />
156
+ `;
157
+ }
158
+
159
+ // Replace placeholder in text
160
+ const placeholder = field.slot_name ? `{{${field.slot_name}}}` : "%%";
161
+ processed_text = processed_text.replace(placeholder, field_html);
162
+ });
163
+
164
+ html += `<div class="bead-cloze-text">${processed_text}</div>`;
165
+ }
166
+
167
+ // Continue button
168
+ html += `
169
+ <div class="bead-cloze-button-container">
170
+ <button class="bead-button bead-continue-button" id="bead-cloze-continue" ${trial.require_all ? "disabled" : ""}>
171
+ ${trial.button_label}
172
+ </button>
173
+ </div>
174
+ `;
175
+
176
+ html += "</div>"; // Close container
177
+
178
+ display_element.innerHTML = html;
179
+
180
+ // Add event listeners for input fields
181
+ const input_fields = display_element.querySelectorAll<HTMLInputElement | HTMLSelectElement>(
182
+ ".bead-cloze-field",
183
+ );
184
+ for (const field of input_fields) {
185
+ const field_index = field.getAttribute("data-field");
186
+ if (field_index === null) continue;
187
+
188
+ // Track when user starts interacting with this field
189
+ field.addEventListener("focus", () => {
190
+ if (field_start_times[field_index] === undefined) {
191
+ field_start_times[field_index] = performance.now();
192
+ }
193
+ });
194
+
195
+ // Track responses
196
+ field.addEventListener("change", () => {
197
+ responses[field_index] = field.value;
198
+
199
+ // Record response time for this field
200
+ const startTime = field_start_times[field_index];
201
+ if (startTime !== undefined) {
202
+ response_times[field_index] = performance.now() - startTime;
203
+ }
204
+
205
+ check_completion();
206
+ });
207
+
208
+ field.addEventListener("input", () => {
209
+ responses[field_index] = field.value;
210
+ check_completion();
211
+ });
212
+ }
213
+
214
+ // Continue button listener
215
+ const continue_button =
216
+ display_element.querySelector<HTMLButtonElement>("#bead-cloze-continue");
217
+ if (continue_button) {
218
+ continue_button.addEventListener("click", () => {
219
+ end_trial();
220
+ });
221
+ }
222
+
223
+ const check_completion = (): void => {
224
+ if (trial.require_all && continue_button) {
225
+ // Check if all fields are filled
226
+ const all_filled = trial.fields.every((_field, index) => {
227
+ const response = responses[index.toString()];
228
+ return response !== undefined && response.trim() !== "";
229
+ });
230
+ continue_button.disabled = !all_filled;
231
+ }
232
+ };
233
+
234
+ const end_trial = (): void => {
235
+ // Gather all responses
236
+ const trial_data: Record<string, unknown> = {
237
+ ...trial.metadata, // Preserve all metadata
238
+ responses: responses,
239
+ response_times: response_times,
240
+ total_rt: performance.now() - start_time,
241
+ };
242
+
243
+ // Clear display
244
+ display_element.innerHTML = "";
245
+
246
+ // End trial
247
+ this.jsPsych.finishTrial(trial_data);
248
+ };
249
+ }
250
+ }
251
+
252
+ export { BeadClozeMultiPlugin };
@@ -0,0 +1,265 @@
1
+ /**
2
+ * bead-forced-choice plugin
3
+ *
4
+ * jsPsych plugin for comparative judgments and forced choice tasks.
5
+ *
6
+ * Features:
7
+ * - Side-by-side stimulus display
8
+ * - Button or keyboard selection
9
+ * - Optional similarity rating after choice
10
+ * - Material Design card layout
11
+ * - Preserves all item and template metadata
12
+ *
13
+ * @author Bead Project
14
+ * @version 0.1.0
15
+ */
16
+
17
+ import type { JsPsych, JsPsychPlugin, KeyboardResponseInfo, PluginInfo } from "../types/jspsych.js";
18
+
19
+ /** Bead rendered elements from metadata */
20
+ interface RenderedElements {
21
+ [key: string]: string;
22
+ }
23
+
24
+ /** Bead item/template metadata */
25
+ interface BeadMetadata {
26
+ rendered_elements?: RenderedElements;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ /** Position type for left/right alternatives */
31
+ type Position = "left" | "right";
32
+
33
+ /** Forced choice trial parameters */
34
+ export interface ForcedChoiceTrialParams {
35
+ /** The prompt/question to display */
36
+ prompt: string;
37
+ /** Array of alternatives to choose from */
38
+ alternatives: string[];
39
+ /** Whether to randomize left/right position */
40
+ randomize_position: boolean;
41
+ /** Enable keyboard responses (1/2 or left/right arrow) */
42
+ enable_keyboard: boolean;
43
+ /** Whether to require a response */
44
+ require_response: boolean;
45
+ /** Text for the continue button (if applicable) */
46
+ button_label: string;
47
+ /** Complete item and template metadata */
48
+ metadata: BeadMetadata;
49
+ }
50
+
51
+ /** Forced choice response data */
52
+ interface ForcedChoiceResponse {
53
+ choice: string | null;
54
+ choice_index: number | null;
55
+ position: Position | null;
56
+ rt: number | null;
57
+ }
58
+
59
+ /** Plugin info constant */
60
+ const info: PluginInfo = {
61
+ name: "bead-forced-choice",
62
+ parameters: {
63
+ prompt: {
64
+ type: 8, // ParameterType.HTML_STRING
65
+ default: "Which do you prefer?",
66
+ },
67
+ alternatives: {
68
+ type: 1, // ParameterType.STRING
69
+ default: [],
70
+ array: true,
71
+ },
72
+ randomize_position: {
73
+ type: 0, // ParameterType.BOOL
74
+ default: true,
75
+ },
76
+ enable_keyboard: {
77
+ type: 0, // ParameterType.BOOL
78
+ default: true,
79
+ },
80
+ require_response: {
81
+ type: 0, // ParameterType.BOOL
82
+ default: true,
83
+ },
84
+ button_label: {
85
+ type: 1, // ParameterType.STRING
86
+ default: "Continue",
87
+ },
88
+ metadata: {
89
+ type: 12, // ParameterType.OBJECT
90
+ default: {},
91
+ },
92
+ },
93
+ };
94
+
95
+ /**
96
+ * BeadForcedChoicePlugin - jsPsych plugin for comparative judgments
97
+ */
98
+ class BeadForcedChoicePlugin implements JsPsychPlugin<typeof info, ForcedChoiceTrialParams> {
99
+ static info = info;
100
+
101
+ private jsPsych: JsPsych;
102
+
103
+ constructor(jsPsych: JsPsych) {
104
+ this.jsPsych = jsPsych;
105
+ }
106
+
107
+ trial(display_element: HTMLElement, trial: ForcedChoiceTrialParams): void {
108
+ const response: ForcedChoiceResponse = {
109
+ choice: null,
110
+ choice_index: null,
111
+ position: null,
112
+ rt: null,
113
+ };
114
+
115
+ const start_time = performance.now();
116
+
117
+ // Extract alternatives from metadata if not provided
118
+ if (trial.alternatives.length === 0 && trial.metadata.rendered_elements) {
119
+ const elements = trial.metadata.rendered_elements;
120
+ const choice_keys = Object.keys(elements)
121
+ .filter(
122
+ (k) => k.startsWith("choice_") || k.startsWith("option_"), // Fixed: startswith -> startsWith
123
+ )
124
+ .sort();
125
+
126
+ if (choice_keys.length >= 2) {
127
+ trial.alternatives = choice_keys.map((k) => elements[k] ?? "");
128
+ } else {
129
+ // Fallback: use all rendered elements
130
+ trial.alternatives = Object.values(elements);
131
+ }
132
+ }
133
+
134
+ // Randomize position if requested
135
+ let left_index = 0;
136
+ let right_index = 1;
137
+ if (trial.randomize_position && Math.random() < 0.5) {
138
+ left_index = 1;
139
+ right_index = 0;
140
+ }
141
+
142
+ // Create HTML
143
+ let html = '<div class="bead-forced-choice-container">';
144
+
145
+ if (trial.prompt) {
146
+ html += `<div class="bead-forced-choice-prompt">${trial.prompt}</div>`;
147
+ }
148
+
149
+ html += '<div class="bead-forced-choice-alternatives">';
150
+
151
+ // Left alternative
152
+ html += `
153
+ <div class="bead-card bead-alternative" data-index="${left_index}" data-position="left">
154
+ <div class="bead-alternative-label">Option 1</div>
155
+ <div class="bead-alternative-content">${trial.alternatives[left_index] ?? "Alternative A"}</div>
156
+ <button class="bead-button bead-choice-button" data-index="${left_index}" data-position="left">
157
+ Select
158
+ </button>
159
+ </div>
160
+ `;
161
+
162
+ // Right alternative
163
+ html += `
164
+ <div class="bead-card bead-alternative" data-index="${right_index}" data-position="right">
165
+ <div class="bead-alternative-label">Option 2</div>
166
+ <div class="bead-alternative-content">${trial.alternatives[right_index] ?? "Alternative B"}</div>
167
+ <button class="bead-button bead-choice-button" data-index="${right_index}" data-position="right">
168
+ Select
169
+ </button>
170
+ </div>
171
+ `;
172
+
173
+ html += "</div>"; // Close alternatives
174
+
175
+ html += "</div>"; // Close container
176
+
177
+ display_element.innerHTML = html;
178
+
179
+ // Add event listeners for choice buttons
180
+ const choice_buttons =
181
+ display_element.querySelectorAll<HTMLButtonElement>(".bead-choice-button");
182
+ for (const button of choice_buttons) {
183
+ button.addEventListener("click", (e) => {
184
+ const target = e.target as HTMLButtonElement;
185
+ const indexAttr = target.getAttribute("data-index");
186
+ const positionAttr = target.getAttribute("data-position") as Position | null;
187
+ if (indexAttr !== null && positionAttr !== null) {
188
+ const index = Number.parseInt(indexAttr, 10);
189
+ select_choice(index, positionAttr);
190
+ }
191
+ });
192
+ }
193
+
194
+ // Keyboard listener
195
+ let keyboard_listener: unknown = null;
196
+ if (trial.enable_keyboard) {
197
+ keyboard_listener = this.jsPsych.pluginAPI.getKeyboardResponse({
198
+ callback_function: (info: KeyboardResponseInfo) => {
199
+ const key = info.key;
200
+ if (key === "1" || key === "ArrowLeft") {
201
+ select_choice(left_index, "left");
202
+ } else if (key === "2" || key === "ArrowRight") {
203
+ select_choice(right_index, "right");
204
+ }
205
+ },
206
+ valid_responses: ["1", "2", "ArrowLeft", "ArrowRight"],
207
+ rt_method: "performance",
208
+ persist: false,
209
+ allow_held_key: false,
210
+ });
211
+ }
212
+
213
+ const select_choice = (index: number, position: Position): void => {
214
+ // Update response
215
+ response.choice = trial.alternatives[index] ?? null;
216
+ response.choice_index = index;
217
+ response.position = position;
218
+ response.rt = performance.now() - start_time;
219
+
220
+ // Visual feedback
221
+ const alternative_cards =
222
+ display_element.querySelectorAll<HTMLDivElement>(".bead-alternative");
223
+ for (const card of alternative_cards) {
224
+ card.classList.remove("selected");
225
+ }
226
+ const selected_card = display_element.querySelector<HTMLDivElement>(
227
+ `.bead-alternative[data-position="${position}"]`,
228
+ );
229
+ if (selected_card) {
230
+ selected_card.classList.add("selected");
231
+ }
232
+
233
+ // End trial immediately or after delay
234
+ setTimeout(() => {
235
+ end_trial();
236
+ }, 300); // Small delay for visual feedback
237
+ };
238
+
239
+ const end_trial = (): void => {
240
+ // Kill keyboard listener
241
+ if (keyboard_listener) {
242
+ this.jsPsych.pluginAPI.cancelKeyboardResponse(keyboard_listener);
243
+ }
244
+
245
+ // Preserve all metadata and add response data
246
+ const trial_data: Record<string, unknown> = {
247
+ ...trial.metadata, // Spread all metadata
248
+ choice: response.choice,
249
+ choice_index: response.choice_index,
250
+ position_chosen: response.position,
251
+ left_index: left_index,
252
+ right_index: right_index,
253
+ rt: response.rt,
254
+ };
255
+
256
+ // Clear display
257
+ display_element.innerHTML = "";
258
+
259
+ // End trial
260
+ this.jsPsych.finishTrial(trial_data);
261
+ };
262
+ }
263
+ }
264
+
265
+ export { BeadForcedChoicePlugin };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Unit tests for bead jsPsych plugins.
3
+ *
4
+ * Tests plugin structure, info validation, and instantiation.
5
+ */
6
+
7
+ import { describe, expect, test, vi } from "vitest";
8
+ import type { JsPsych } from "../types/jspsych.js";
9
+ import { BeadClozeMultiPlugin } from "./cloze-dropdown.js";
10
+ import { BeadForcedChoicePlugin } from "./forced-choice.js";
11
+ import { BeadRatingPlugin } from "./rating.js";
12
+
13
+ // Mock jsPsych instance
14
+ function createMockJsPsych(): JsPsych {
15
+ return {
16
+ pluginAPI: {
17
+ getKeyboardResponse: vi.fn(),
18
+ cancelKeyboardResponse: vi.fn(),
19
+ },
20
+ finishTrial: vi.fn(),
21
+ } as unknown as JsPsych;
22
+ }
23
+
24
+ describe("bead-rating plugin", () => {
25
+ describe("info structure", () => {
26
+ test("has correct plugin name", () => {
27
+ expect(BeadRatingPlugin.info.name).toBe("bead-rating");
28
+ });
29
+
30
+ test("has required parameters", () => {
31
+ const params = BeadRatingPlugin.info.parameters;
32
+ expect(params["prompt"]).toBeDefined();
33
+ expect(params["scale_min"]).toBeDefined();
34
+ expect(params["scale_max"]).toBeDefined();
35
+ expect(params["scale_labels"]).toBeDefined();
36
+ expect(params["require_response"]).toBeDefined();
37
+ expect(params["button_label"]).toBeDefined();
38
+ expect(params["metadata"]).toBeDefined();
39
+ });
40
+
41
+ test("has correct parameter defaults", () => {
42
+ const params = BeadRatingPlugin.info.parameters;
43
+ expect(params["scale_min"]?.default).toBe(1);
44
+ expect(params["scale_max"]?.default).toBe(7);
45
+ expect(params["require_response"]?.default).toBe(true);
46
+ expect(params["button_label"]?.default).toBe("Continue");
47
+ });
48
+ });
49
+
50
+ describe("plugin instantiation", () => {
51
+ test("can be instantiated", () => {
52
+ const mockJsPsych = createMockJsPsych();
53
+ const plugin = new BeadRatingPlugin(mockJsPsych);
54
+ expect(plugin).toBeDefined();
55
+ });
56
+
57
+ test("has trial method", () => {
58
+ const mockJsPsych = createMockJsPsych();
59
+ const plugin = new BeadRatingPlugin(mockJsPsych);
60
+ expect(typeof plugin.trial).toBe("function");
61
+ });
62
+ });
63
+ });
64
+
65
+ describe("bead-forced-choice plugin", () => {
66
+ describe("info structure", () => {
67
+ test("has correct plugin name", () => {
68
+ expect(BeadForcedChoicePlugin.info.name).toBe("bead-forced-choice");
69
+ });
70
+
71
+ test("has required parameters", () => {
72
+ const params = BeadForcedChoicePlugin.info.parameters;
73
+ expect(params["alternatives"]).toBeDefined();
74
+ expect(params["prompt"]).toBeDefined();
75
+ expect(params["button_label"]).toBeDefined();
76
+ expect(params["require_response"]).toBeDefined();
77
+ expect(params["randomize_position"]).toBeDefined();
78
+ expect(params["enable_keyboard"]).toBeDefined();
79
+ expect(params["metadata"]).toBeDefined();
80
+ });
81
+
82
+ test("has correct parameter defaults", () => {
83
+ const params = BeadForcedChoicePlugin.info.parameters;
84
+ expect(params["require_response"]?.default).toBe(true);
85
+ expect(params["randomize_position"]?.default).toBe(true);
86
+ expect(params["enable_keyboard"]?.default).toBe(true);
87
+ expect(params["button_label"]?.default).toBe("Continue");
88
+ });
89
+ });
90
+
91
+ describe("plugin instantiation", () => {
92
+ test("can be instantiated", () => {
93
+ const mockJsPsych = createMockJsPsych();
94
+ const plugin = new BeadForcedChoicePlugin(mockJsPsych);
95
+ expect(plugin).toBeDefined();
96
+ });
97
+
98
+ test("has trial method", () => {
99
+ const mockJsPsych = createMockJsPsych();
100
+ const plugin = new BeadForcedChoicePlugin(mockJsPsych);
101
+ expect(typeof plugin.trial).toBe("function");
102
+ });
103
+ });
104
+ });
105
+
106
+ describe("bead-cloze-multi plugin", () => {
107
+ describe("info structure", () => {
108
+ test("has correct plugin name", () => {
109
+ expect(BeadClozeMultiPlugin.info.name).toBe("bead-cloze-multi");
110
+ });
111
+
112
+ test("has required parameters", () => {
113
+ const params = BeadClozeMultiPlugin.info.parameters;
114
+ expect(params["text"]).toBeDefined();
115
+ expect(params["fields"]).toBeDefined();
116
+ expect(params["require_all"]).toBeDefined();
117
+ expect(params["button_label"]).toBeDefined();
118
+ expect(params["metadata"]).toBeDefined();
119
+ });
120
+
121
+ test("has correct parameter defaults", () => {
122
+ const params = BeadClozeMultiPlugin.info.parameters;
123
+ expect(params["require_all"]?.default).toBe(true);
124
+ expect(params["button_label"]?.default).toBe("Continue");
125
+ });
126
+ });
127
+
128
+ describe("plugin instantiation", () => {
129
+ test("can be instantiated", () => {
130
+ const mockJsPsych = createMockJsPsych();
131
+ const plugin = new BeadClozeMultiPlugin(mockJsPsych);
132
+ expect(plugin).toBeDefined();
133
+ });
134
+
135
+ test("has trial method", () => {
136
+ const mockJsPsych = createMockJsPsych();
137
+ const plugin = new BeadClozeMultiPlugin(mockJsPsych);
138
+ expect(typeof plugin.trial).toBe("function");
139
+ });
140
+ });
141
+ });