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,299 @@
1
+ """JavaScript randomizer code generator from OrderingConstraints.
2
+
3
+ This module converts Python OrderingConstraint models into JavaScript code
4
+ that performs constraint-aware trial randomization at jsPsych runtime. This
5
+ enables per-participant randomization while satisfying all ordering constraints.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from uuid import UUID
13
+
14
+ from jinja2 import Environment, FileSystemLoader
15
+
16
+ from bead.data.base import JsonValue
17
+ from bead.lists.constraints import OrderingConstraint
18
+
19
+
20
+ def generate_randomizer_function(
21
+ item_ids: list[UUID],
22
+ constraints: list[OrderingConstraint],
23
+ metadata: dict[UUID, dict[str, JsonValue]],
24
+ ) -> str:
25
+ """Generate JavaScript code for constraint-aware trial randomization.
26
+
27
+ This function converts OrderingConstraints into JavaScript code that can
28
+ randomize trial order at runtime while satisfying all constraints. The
29
+ generated code uses seeded randomization for reproducibility and rejection
30
+ sampling to satisfy constraints.
31
+
32
+ Parameters
33
+ ----------
34
+ item_ids : list[UUID]
35
+ List of item IDs included in the experiment.
36
+ constraints : list[OrderingConstraint]
37
+ Ordering constraints to enforce.
38
+ metadata : dict[UUID, dict[str, JsonValue]]
39
+ Item metadata needed for constraint checking (keyed by item UUID).
40
+
41
+ Returns
42
+ -------
43
+ str
44
+ JavaScript code implementing randomizeTrials() function.
45
+
46
+ Examples
47
+ --------
48
+ >>> from uuid import UUID
49
+ >>> item1 = UUID("12345678-1234-5678-1234-567812345678")
50
+ >>> item2 = UUID("87654321-4321-8765-4321-876543218765")
51
+ >>> constraint = OrderingConstraint(
52
+ ... no_adjacent_property="item_metadata.condition"
53
+ ... )
54
+ >>> metadata = {
55
+ ... item1: {"condition": "A"},
56
+ ... item2: {"condition": "B"}
57
+ ... }
58
+ >>> js_code = generate_randomizer_function(
59
+ ... [item1, item2],
60
+ ... [constraint],
61
+ ... metadata
62
+ ... )
63
+ >>> "function randomizeTrials" in js_code
64
+ True
65
+ >>> "checkNoAdjacentConstraints" in js_code
66
+ True
67
+ """
68
+ # prepare template context
69
+ context = _prepare_template_context(item_ids, constraints, metadata)
70
+
71
+ # load and render template
72
+ template_dir = Path(__file__).parent / "templates"
73
+ env = Environment(loader=FileSystemLoader(str(template_dir)))
74
+ template = env.get_template("randomizer.js.template")
75
+
76
+ return template.render(**context)
77
+
78
+
79
+ def _prepare_template_context(
80
+ item_ids: list[UUID],
81
+ constraints: list[OrderingConstraint],
82
+ metadata: dict[UUID, dict[str, JsonValue]],
83
+ ) -> dict[str, JsonValue]:
84
+ """Prepare Jinja2 template context from constraints.
85
+
86
+ Parameters
87
+ ----------
88
+ item_ids : list[UUID]
89
+ Item IDs in the experiment.
90
+ constraints : list[OrderingConstraint]
91
+ Ordering constraints.
92
+ metadata : dict[UUID, dict[str, JsonValue]]
93
+ Item metadata.
94
+
95
+ Returns
96
+ -------
97
+ dict[str, JsonValue]
98
+ Template context for Jinja2 rendering.
99
+ """
100
+ context: dict[str, JsonValue] = {
101
+ "metadata_json": _serialize_metadata(metadata),
102
+ "has_practice_items": False,
103
+ "practice_property": "",
104
+ "has_blocking": False,
105
+ "block_property": "",
106
+ "randomize_within_blocks": True,
107
+ "has_precedence": False,
108
+ "precedence_pairs_json": "[]",
109
+ "has_no_adjacent": False,
110
+ "no_adjacent_property": "",
111
+ "has_distance": False,
112
+ "distance_constraints_json": "[]",
113
+ }
114
+
115
+ # combine all constraints (multiple OrderingConstraints can be active)
116
+ for constraint in constraints:
117
+ # practice items
118
+ if constraint.practice_item_property:
119
+ context["has_practice_items"] = True
120
+ # extract property name from path
121
+ # (e.g., "item_metadata.is_practice" -> "is_practice")
122
+ context["practice_property"] = _extract_property_name(
123
+ constraint.practice_item_property
124
+ )
125
+
126
+ # blocking
127
+ if constraint.block_by_property:
128
+ context["has_blocking"] = True
129
+ context["block_property"] = _extract_property_name(
130
+ constraint.block_by_property
131
+ )
132
+ context["randomize_within_blocks"] = constraint.randomize_within_blocks
133
+
134
+ # precedence
135
+ if constraint.precedence_pairs:
136
+ context["has_precedence"] = True
137
+ # convert UUID pairs to string pairs for JSON
138
+ pairs = [[str(a), str(b)] for a, b in constraint.precedence_pairs]
139
+ context["precedence_pairs_json"] = json.dumps(pairs)
140
+
141
+ # no-adjacency
142
+ if constraint.no_adjacent_property:
143
+ context["has_no_adjacent"] = True
144
+ # extract property name since metadata is already extracted
145
+ context["no_adjacent_property"] = _extract_property_name(
146
+ constraint.no_adjacent_property
147
+ )
148
+
149
+ # distance constraints
150
+ if constraint.min_distance or constraint.max_distance:
151
+ context["has_distance"] = True
152
+ # generate distance constraints for all item pairs
153
+ distance_constraints = _generate_distance_constraints(
154
+ item_ids, constraint, metadata
155
+ )
156
+ context["distance_constraints_json"] = json.dumps(distance_constraints)
157
+
158
+ return context
159
+
160
+
161
+ def _serialize_metadata(metadata: dict[UUID, dict[str, JsonValue]]) -> str:
162
+ """Serialize metadata dictionary to JSON.
163
+
164
+ Converts UUID keys to strings for JSON serialization.
165
+
166
+ Parameters
167
+ ----------
168
+ metadata : dict[UUID, dict[str, JsonValue]]
169
+ Item metadata with UUID keys.
170
+
171
+ Returns
172
+ -------
173
+ str
174
+ JSON string of metadata.
175
+ """
176
+ # convert UUID keys to strings
177
+ serializable = {str(k): v for k, v in metadata.items()}
178
+ return json.dumps(serializable, indent=2)
179
+
180
+
181
+ def _extract_property_name(property_path: str) -> str:
182
+ """Extract final property name from dot-notation path.
183
+
184
+ Parameters
185
+ ----------
186
+ property_path : str
187
+ Dot-notation property path (e.g., "item_metadata.is_practice").
188
+
189
+ Returns
190
+ -------
191
+ str
192
+ Final property name (e.g., "is_practice").
193
+
194
+ Examples
195
+ --------
196
+ >>> _extract_property_name("item_metadata.is_practice")
197
+ 'is_practice'
198
+ >>> _extract_property_name("condition")
199
+ 'condition'
200
+ """
201
+ parts = property_path.split(".")
202
+ return parts[-1]
203
+
204
+
205
+ def _generate_distance_constraints(
206
+ item_ids: list[UUID],
207
+ constraint: OrderingConstraint,
208
+ metadata: dict[UUID, dict[str, JsonValue]],
209
+ ) -> list[dict[str, str | int | None]]:
210
+ """Generate distance constraints for all relevant item pairs.
211
+
212
+ Distance constraints are applied to items that share the same value
213
+ for the no_adjacent_property (if specified).
214
+
215
+ Parameters
216
+ ----------
217
+ item_ids : list[UUID]
218
+ Item IDs in the experiment.
219
+ constraint : OrderingConstraint
220
+ Ordering constraint with distance specifications.
221
+ metadata : dict[UUID, dict[str, JsonValue]]
222
+ Item metadata.
223
+
224
+ Returns
225
+ -------
226
+ list[dict[str, str | int | None]]
227
+ List of distance constraint specifications.
228
+ """
229
+ distance_constraints: list[dict[str, str | int | None]] = []
230
+
231
+ # group items by property value if no_adjacent_property is set
232
+ if constraint.no_adjacent_property:
233
+ property_path = constraint.no_adjacent_property
234
+ # extract just the property name from the path
235
+ # (e.g., "condition" from "item_metadata.condition")
236
+ # because metadata is already extracted from items
237
+ property_name = _extract_property_name(property_path)
238
+
239
+ # group items by property value
240
+ groups: dict[JsonValue, list[UUID]] = {}
241
+ for item_id in item_ids:
242
+ item_meta = metadata.get(item_id, {})
243
+ value = item_meta.get(property_name)
244
+
245
+ if value is not None:
246
+ if value not in groups:
247
+ groups[value] = []
248
+ groups[value].append(item_id)
249
+
250
+ # create pairwise distance constraints within each group
251
+ for _value, item_group in groups.items():
252
+ if len(item_group) > 1:
253
+ # create constraints for all pairs in this group
254
+ for i, item1 in enumerate(item_group):
255
+ for item2 in item_group[i + 1 :]:
256
+ distance_constraints.append(
257
+ {
258
+ "item1_id": str(item1),
259
+ "item2_id": str(item2),
260
+ "min_distance": constraint.min_distance,
261
+ "max_distance": constraint.max_distance,
262
+ }
263
+ )
264
+
265
+ return distance_constraints
266
+
267
+
268
+ def _get_nested_property(obj: dict[str, JsonValue], path: str) -> JsonValue: # pyright: ignore[reportUnusedFunction]
269
+ """Get nested property from dictionary using dot notation.
270
+
271
+ Parameters
272
+ ----------
273
+ obj : dict[str, JsonValue]
274
+ Object to query.
275
+ path : str
276
+ Property path (e.g., "item_metadata.condition").
277
+
278
+ Returns
279
+ -------
280
+ JsonValue
281
+ Property value or None if not found.
282
+
283
+ Examples
284
+ --------
285
+ >>> obj = {"item_metadata": {"condition": "A"}}
286
+ >>> _get_nested_property(obj, "item_metadata.condition")
287
+ 'A'
288
+ >>> _get_nested_property(obj, "missing.path") is None
289
+ True
290
+ """
291
+ parts = path.split(".")
292
+ current = obj
293
+
294
+ for part in parts:
295
+ if not isinstance(current, dict) or part not in current:
296
+ return None
297
+ current = current[part]
298
+
299
+ return current
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Unit tests for list-distributor.ts helper functions.
3
+ *
4
+ * Tests the pure functions that can be tested without JATOS.
5
+ * The main ListDistributor class requires JATOS batch sessions.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+
10
+ // Helper functions copied from list-distributor.ts for isolated testing.
11
+ // These are internal functions not exported from the module.
12
+
13
+ function generateBalancedLatinSquare(n: number): number[][] {
14
+ const square: number[][] = [];
15
+ for (let i = 0; i < n; i++) {
16
+ const row: number[] = [];
17
+ for (let j = 0; j < n; j++) {
18
+ if (i % 2 === 0) {
19
+ row.push((Math.floor(i / 2) + j) % n);
20
+ } else {
21
+ row.push((Math.floor(i / 2) + n - j) % n);
22
+ }
23
+ }
24
+ square.push(row);
25
+ }
26
+ return square;
27
+ }
28
+
29
+ function shuffleArray<T>(array: T[]): void {
30
+ for (let i = array.length - 1; i > 0; i--) {
31
+ const j = Math.floor(Math.random() * (i + 1));
32
+ const temp = array[i];
33
+ const swapVal = array[j];
34
+ if (temp !== undefined && swapVal !== undefined) {
35
+ array[i] = swapVal;
36
+ array[j] = temp;
37
+ }
38
+ }
39
+ }
40
+
41
+ interface ExperimentList {
42
+ id: string;
43
+ name: string;
44
+ list_number: number;
45
+ list_metadata?: Record<string, unknown>;
46
+ item_refs: string[];
47
+ }
48
+
49
+ interface QueueEntry {
50
+ list_index: number;
51
+ list_id: string;
52
+ }
53
+
54
+ interface DistributionConfig {
55
+ strategy_type: string;
56
+ strategy_config?: {
57
+ factors?: string[];
58
+ weight_expression?: string;
59
+ normalize_weights?: boolean;
60
+ participants_per_list?: number;
61
+ allow_overflow?: boolean;
62
+ filter_expression?: string;
63
+ rank_expression?: string;
64
+ rank_ascending?: boolean;
65
+ };
66
+ max_participants?: number;
67
+ }
68
+
69
+ function initializeRandom(
70
+ _config: DistributionConfig,
71
+ lists: ExperimentList[],
72
+ maxParticipants: number,
73
+ ): QueueEntry[] {
74
+ const queue: QueueEntry[] = [];
75
+ const perList = Math.ceil(maxParticipants / lists.length);
76
+ for (let i = 0; i < lists.length; i++) {
77
+ const list = lists[i];
78
+ if (list) {
79
+ for (let j = 0; j < perList; j++) {
80
+ queue.push({ list_index: i, list_id: list.id });
81
+ }
82
+ }
83
+ }
84
+ shuffleArray(queue);
85
+ return queue;
86
+ }
87
+
88
+ function initializeSequential(
89
+ _config: DistributionConfig,
90
+ lists: ExperimentList[],
91
+ maxParticipants: number,
92
+ ): QueueEntry[] {
93
+ const queue: QueueEntry[] = [];
94
+ for (let i = 0; i < maxParticipants; i++) {
95
+ const listIndex = i % lists.length;
96
+ const list = lists[listIndex];
97
+ if (list) {
98
+ queue.push({ list_index: listIndex, list_id: list.id });
99
+ }
100
+ }
101
+ return queue;
102
+ }
103
+
104
+ function initializeLatinSquare(
105
+ _config: DistributionConfig,
106
+ lists: ExperimentList[],
107
+ ): { queue: QueueEntry[]; matrix: number[][] } {
108
+ const matrix = generateBalancedLatinSquare(lists.length);
109
+ const queue: QueueEntry[] = [];
110
+
111
+ for (let row = 0; row < matrix.length; row++) {
112
+ const matrixRow = matrix[row];
113
+ if (matrixRow) {
114
+ for (let col = 0; col < matrixRow.length; col++) {
115
+ const listIndex = matrixRow[col];
116
+ if (listIndex !== undefined) {
117
+ const list = lists[listIndex];
118
+ if (list) {
119
+ queue.push({ list_index: listIndex, list_id: list.id });
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ return { queue, matrix };
127
+ }
128
+
129
+ function initializeQuotaBased(
130
+ config: DistributionConfig,
131
+ lists: ExperimentList[],
132
+ ): { queue: QueueEntry[]; quotas: Record<number, number> } {
133
+ const quota = config.strategy_config?.participants_per_list ?? 10;
134
+ const quotas: Record<number, number> = {};
135
+ const queue: QueueEntry[] = [];
136
+
137
+ for (let i = 0; i < lists.length; i++) {
138
+ quotas[i] = quota;
139
+ const list = lists[i];
140
+ if (list) {
141
+ for (let j = 0; j < quota; j++) {
142
+ queue.push({ list_index: i, list_id: list.id });
143
+ }
144
+ }
145
+ }
146
+
147
+ shuffleArray(queue);
148
+ return { queue, quotas };
149
+ }
150
+
151
+ describe("generateBalancedLatinSquare", () => {
152
+ test("generates correct 4x4 balanced Latin square", () => {
153
+ const square = generateBalancedLatinSquare(4);
154
+
155
+ expect(square).toHaveLength(4);
156
+ expect(square[0]).toHaveLength(4);
157
+
158
+ // Check each row contains 0,1,2,3
159
+ for (const row of square) {
160
+ const sorted = [...row].sort();
161
+ expect(sorted).toEqual([0, 1, 2, 3]);
162
+ }
163
+ });
164
+
165
+ test("generates correct 2x2 balanced Latin square", () => {
166
+ const square = generateBalancedLatinSquare(2);
167
+
168
+ expect(square).toHaveLength(2);
169
+ // Row 0 (even): (floor(0/2) + j) % 2 = (0 + j) % 2
170
+ expect(square[0]).toEqual([0, 1]);
171
+ // Row 1 (odd): (floor(1/2) + 2 - j) % 2 = (0 + 2 - j) % 2
172
+ expect(square[1]).toEqual([0, 1]);
173
+ });
174
+
175
+ test("generates square with balanced counterbalancing", () => {
176
+ const square = generateBalancedLatinSquare(4);
177
+
178
+ // Verify first column follows the algorithm pattern
179
+ // i=0 (even): (floor(0/2) + 0) % 4 = 0
180
+ expect(square[0]?.[0]).toBe(0);
181
+ // i=1 (odd): (floor(1/2) + 4 - 0) % 4 = (0 + 4) % 4 = 0
182
+ expect(square[1]?.[0]).toBe(0);
183
+ // i=2 (even): (floor(2/2) + 0) % 4 = 1
184
+ expect(square[2]?.[0]).toBe(1);
185
+ // i=3 (odd): (floor(3/2) + 4 - 0) % 4 = (1 + 4) % 4 = 1
186
+ expect(square[3]?.[0]).toBe(1);
187
+ });
188
+ });
189
+
190
+ describe("initializeRandom", () => {
191
+ test("generates queue with equal entries per list", () => {
192
+ const lists: ExperimentList[] = [
193
+ { id: "list_1", name: "list_1", list_number: 0, item_refs: [] },
194
+ { id: "list_2", name: "list_2", list_number: 1, item_refs: [] },
195
+ { id: "list_3", name: "list_3", list_number: 2, item_refs: [] },
196
+ ];
197
+ const config: DistributionConfig = { strategy_type: "random" };
198
+ const maxParticipants = 30;
199
+
200
+ const queue = initializeRandom(config, lists, maxParticipants);
201
+
202
+ expect(queue.length).toBe(30);
203
+ // Count entries per list
204
+ const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0 };
205
+ for (const entry of queue) {
206
+ counts[entry.list_index] = (counts[entry.list_index] ?? 0) + 1;
207
+ }
208
+ // Should be roughly equal (10 each)
209
+ expect(counts[0]).toBeGreaterThanOrEqual(9);
210
+ expect(counts[0]).toBeLessThanOrEqual(11);
211
+ });
212
+ });
213
+
214
+ describe("initializeSequential", () => {
215
+ test("generates sequential queue with round-robin", () => {
216
+ const lists: ExperimentList[] = [
217
+ { id: "list_1", name: "list_1", list_number: 0, item_refs: [] },
218
+ { id: "list_2", name: "list_2", list_number: 1, item_refs: [] },
219
+ { id: "list_3", name: "list_3", list_number: 2, item_refs: [] },
220
+ ];
221
+ const config: DistributionConfig = { strategy_type: "sequential" };
222
+ const maxParticipants = 10;
223
+
224
+ const queue = initializeSequential(config, lists, maxParticipants);
225
+
226
+ expect(queue.length).toBe(10);
227
+ expect(queue[0]?.list_index).toBe(0);
228
+ expect(queue[1]?.list_index).toBe(1);
229
+ expect(queue[2]?.list_index).toBe(2);
230
+ expect(queue[3]?.list_index).toBe(0); // Wraps around (3 % 3 = 0)
231
+ expect(queue[4]?.list_index).toBe(1); // 4 % 3 = 1
232
+ expect(queue[9]?.list_index).toBe(0); // 9 % 3 = 0
233
+ });
234
+ });
235
+
236
+ describe("initializeLatinSquare", () => {
237
+ test("generates queue from Latin square matrix", () => {
238
+ const lists: ExperimentList[] = [
239
+ { id: "list_1", name: "list_1", list_number: 0, item_refs: [] },
240
+ { id: "list_2", name: "list_2", list_number: 1, item_refs: [] },
241
+ { id: "list_3", name: "list_3", list_number: 2, item_refs: [] },
242
+ ];
243
+ const config: DistributionConfig = { strategy_type: "latin_square" };
244
+
245
+ const { queue, matrix } = initializeLatinSquare(config, lists);
246
+
247
+ expect(matrix).toHaveLength(3);
248
+ expect(queue.length).toBe(9); // 3x3 matrix
249
+ // Verify queue entries match matrix
250
+ let queueIndex = 0;
251
+ for (let row = 0; row < matrix.length; row++) {
252
+ const matrixRow = matrix[row];
253
+ if (matrixRow) {
254
+ for (let col = 0; col < matrixRow.length; col++) {
255
+ expect(queue[queueIndex]?.list_index).toBe(matrixRow[col]);
256
+ queueIndex++;
257
+ }
258
+ }
259
+ }
260
+ });
261
+
262
+ test("includes both queue and matrix in result", () => {
263
+ const lists: ExperimentList[] = [
264
+ { id: "list_1", name: "list_1", list_number: 0, item_refs: [] },
265
+ { id: "list_2", name: "list_2", list_number: 1, item_refs: [] },
266
+ ];
267
+ const config: DistributionConfig = { strategy_type: "latin_square" };
268
+
269
+ const result = initializeLatinSquare(config, lists);
270
+
271
+ expect(result).toHaveProperty("queue");
272
+ expect(result).toHaveProperty("matrix");
273
+ expect(Array.isArray(result.queue)).toBe(true);
274
+ expect(Array.isArray(result.matrix)).toBe(true);
275
+ });
276
+ });
277
+
278
+ describe("initializeQuotaBased", () => {
279
+ test("generates queue and quotas correctly", () => {
280
+ const lists: ExperimentList[] = [
281
+ { id: "list_1", name: "list_1", list_number: 0, item_refs: [] },
282
+ { id: "list_2", name: "list_2", list_number: 1, item_refs: [] },
283
+ { id: "list_3", name: "list_3", list_number: 2, item_refs: [] },
284
+ ];
285
+ const config: DistributionConfig = {
286
+ strategy_type: "quota_based",
287
+ strategy_config: {
288
+ participants_per_list: 5,
289
+ },
290
+ };
291
+
292
+ const { queue, quotas } = initializeQuotaBased(config, lists);
293
+
294
+ expect(queue.length).toBe(15); // 3 lists * 5 participants
295
+ expect(quotas[0]).toBe(5);
296
+ expect(quotas[1]).toBe(5);
297
+ expect(quotas[2]).toBe(5);
298
+
299
+ // Count entries per list
300
+ const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0 };
301
+ for (const entry of queue) {
302
+ counts[entry.list_index] = (counts[entry.list_index] ?? 0) + 1;
303
+ }
304
+ expect(counts[0]).toBe(5);
305
+ expect(counts[1]).toBe(5);
306
+ expect(counts[2]).toBe(5);
307
+ });
308
+
309
+ test("includes both queue and quotas in result", () => {
310
+ const lists: ExperimentList[] = [
311
+ { id: "list_1", name: "list_1", list_number: 0, item_refs: [] },
312
+ ];
313
+ const config: DistributionConfig = {
314
+ strategy_type: "quota_based",
315
+ strategy_config: {
316
+ participants_per_list: 10,
317
+ },
318
+ };
319
+
320
+ const result = initializeQuotaBased(config, lists);
321
+
322
+ expect(result).toHaveProperty("queue");
323
+ expect(result).toHaveProperty("quotas");
324
+ expect(Array.isArray(result.queue)).toBe(true);
325
+ expect(typeof result.quotas).toBe("object");
326
+ });
327
+ });