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.
- bead/__init__.py +11 -0
- bead/__main__.py +11 -0
- bead/active_learning/__init__.py +15 -0
- bead/active_learning/config.py +231 -0
- bead/active_learning/loop.py +566 -0
- bead/active_learning/models/__init__.py +24 -0
- bead/active_learning/models/base.py +852 -0
- bead/active_learning/models/binary.py +910 -0
- bead/active_learning/models/categorical.py +943 -0
- bead/active_learning/models/cloze.py +862 -0
- bead/active_learning/models/forced_choice.py +956 -0
- bead/active_learning/models/free_text.py +773 -0
- bead/active_learning/models/lora.py +365 -0
- bead/active_learning/models/magnitude.py +835 -0
- bead/active_learning/models/multi_select.py +795 -0
- bead/active_learning/models/ordinal_scale.py +811 -0
- bead/active_learning/models/peft_adapter.py +155 -0
- bead/active_learning/models/random_effects.py +639 -0
- bead/active_learning/selection.py +354 -0
- bead/active_learning/strategies.py +391 -0
- bead/active_learning/trainers/__init__.py +26 -0
- bead/active_learning/trainers/base.py +210 -0
- bead/active_learning/trainers/data_collator.py +172 -0
- bead/active_learning/trainers/dataset_utils.py +261 -0
- bead/active_learning/trainers/huggingface.py +304 -0
- bead/active_learning/trainers/lightning.py +324 -0
- bead/active_learning/trainers/metrics.py +424 -0
- bead/active_learning/trainers/mixed_effects.py +551 -0
- bead/active_learning/trainers/model_wrapper.py +509 -0
- bead/active_learning/trainers/registry.py +104 -0
- bead/adapters/__init__.py +11 -0
- bead/adapters/huggingface.py +61 -0
- bead/behavioral/__init__.py +116 -0
- bead/behavioral/analytics.py +646 -0
- bead/behavioral/extraction.py +343 -0
- bead/behavioral/merging.py +343 -0
- bead/cli/__init__.py +11 -0
- bead/cli/active_learning.py +513 -0
- bead/cli/active_learning_commands.py +779 -0
- bead/cli/completion.py +359 -0
- bead/cli/config.py +624 -0
- bead/cli/constraint_builders.py +286 -0
- bead/cli/deployment.py +859 -0
- bead/cli/deployment_trials.py +493 -0
- bead/cli/deployment_ui.py +332 -0
- bead/cli/display.py +378 -0
- bead/cli/items.py +960 -0
- bead/cli/items_factories.py +776 -0
- bead/cli/list_constraints.py +714 -0
- bead/cli/lists.py +490 -0
- bead/cli/main.py +430 -0
- bead/cli/models.py +877 -0
- bead/cli/resource_loaders.py +621 -0
- bead/cli/resources.py +1036 -0
- bead/cli/shell.py +356 -0
- bead/cli/simulate.py +840 -0
- bead/cli/templates.py +1158 -0
- bead/cli/training.py +1080 -0
- bead/cli/utils.py +614 -0
- bead/cli/workflow.py +1273 -0
- bead/config/__init__.py +68 -0
- bead/config/active_learning.py +1009 -0
- bead/config/config.py +192 -0
- bead/config/defaults.py +118 -0
- bead/config/deployment.py +217 -0
- bead/config/env.py +147 -0
- bead/config/item.py +45 -0
- bead/config/list.py +193 -0
- bead/config/loader.py +149 -0
- bead/config/logging.py +42 -0
- bead/config/model.py +49 -0
- bead/config/paths.py +46 -0
- bead/config/profiles.py +320 -0
- bead/config/resources.py +47 -0
- bead/config/serialization.py +210 -0
- bead/config/simulation.py +206 -0
- bead/config/template.py +238 -0
- bead/config/validation.py +267 -0
- bead/data/__init__.py +65 -0
- bead/data/base.py +87 -0
- bead/data/identifiers.py +97 -0
- bead/data/language_codes.py +61 -0
- bead/data/metadata.py +270 -0
- bead/data/range.py +123 -0
- bead/data/repository.py +358 -0
- bead/data/serialization.py +249 -0
- bead/data/timestamps.py +89 -0
- bead/data/validation.py +349 -0
- bead/data_collection/__init__.py +11 -0
- bead/data_collection/jatos.py +223 -0
- bead/data_collection/merger.py +154 -0
- bead/data_collection/prolific.py +198 -0
- bead/deployment/__init__.py +5 -0
- bead/deployment/distribution.py +402 -0
- bead/deployment/jatos/__init__.py +1 -0
- bead/deployment/jatos/api.py +200 -0
- bead/deployment/jatos/exporter.py +210 -0
- bead/deployment/jspsych/__init__.py +9 -0
- bead/deployment/jspsych/biome.json +44 -0
- bead/deployment/jspsych/config.py +411 -0
- bead/deployment/jspsych/generator.py +598 -0
- bead/deployment/jspsych/package.json +51 -0
- bead/deployment/jspsych/pnpm-lock.yaml +2141 -0
- bead/deployment/jspsych/randomizer.py +299 -0
- bead/deployment/jspsych/src/lib/list-distributor.test.ts +327 -0
- bead/deployment/jspsych/src/lib/list-distributor.ts +1282 -0
- bead/deployment/jspsych/src/lib/randomizer.test.ts +232 -0
- bead/deployment/jspsych/src/lib/randomizer.ts +367 -0
- bead/deployment/jspsych/src/plugins/cloze-dropdown.ts +252 -0
- bead/deployment/jspsych/src/plugins/forced-choice.ts +265 -0
- bead/deployment/jspsych/src/plugins/plugins.test.ts +141 -0
- bead/deployment/jspsych/src/plugins/rating.ts +248 -0
- bead/deployment/jspsych/src/slopit/index.ts +9 -0
- bead/deployment/jspsych/src/types/jatos.d.ts +256 -0
- bead/deployment/jspsych/src/types/jspsych.d.ts +228 -0
- bead/deployment/jspsych/templates/experiment.css +1 -0
- bead/deployment/jspsych/templates/experiment.js.template +289 -0
- bead/deployment/jspsych/templates/index.html +51 -0
- bead/deployment/jspsych/templates/randomizer.js +241 -0
- bead/deployment/jspsych/templates/randomizer.js.template +313 -0
- bead/deployment/jspsych/trials.py +723 -0
- bead/deployment/jspsych/tsconfig.json +23 -0
- bead/deployment/jspsych/tsup.config.ts +30 -0
- bead/deployment/jspsych/ui/__init__.py +1 -0
- bead/deployment/jspsych/ui/components.py +383 -0
- bead/deployment/jspsych/ui/styles.py +411 -0
- bead/dsl/__init__.py +80 -0
- bead/dsl/ast.py +168 -0
- bead/dsl/context.py +178 -0
- bead/dsl/errors.py +71 -0
- bead/dsl/evaluator.py +570 -0
- bead/dsl/grammar.lark +81 -0
- bead/dsl/parser.py +231 -0
- bead/dsl/stdlib.py +929 -0
- bead/evaluation/__init__.py +13 -0
- bead/evaluation/convergence.py +485 -0
- bead/evaluation/interannotator.py +398 -0
- bead/items/__init__.py +40 -0
- bead/items/adapters/__init__.py +70 -0
- bead/items/adapters/anthropic.py +224 -0
- bead/items/adapters/api_utils.py +167 -0
- bead/items/adapters/base.py +216 -0
- bead/items/adapters/google.py +259 -0
- bead/items/adapters/huggingface.py +1074 -0
- bead/items/adapters/openai.py +323 -0
- bead/items/adapters/registry.py +202 -0
- bead/items/adapters/sentence_transformers.py +224 -0
- bead/items/adapters/togetherai.py +309 -0
- bead/items/binary.py +515 -0
- bead/items/cache.py +558 -0
- bead/items/categorical.py +593 -0
- bead/items/cloze.py +757 -0
- bead/items/constructor.py +784 -0
- bead/items/forced_choice.py +413 -0
- bead/items/free_text.py +681 -0
- bead/items/generation.py +432 -0
- bead/items/item.py +396 -0
- bead/items/item_template.py +787 -0
- bead/items/magnitude.py +573 -0
- bead/items/multi_select.py +621 -0
- bead/items/ordinal_scale.py +569 -0
- bead/items/scoring.py +448 -0
- bead/items/validation.py +723 -0
- bead/lists/__init__.py +30 -0
- bead/lists/balancer.py +263 -0
- bead/lists/constraints.py +1067 -0
- bead/lists/experiment_list.py +286 -0
- bead/lists/list_collection.py +378 -0
- bead/lists/partitioner.py +1141 -0
- bead/lists/stratification.py +254 -0
- bead/participants/__init__.py +73 -0
- bead/participants/collection.py +699 -0
- bead/participants/merging.py +312 -0
- bead/participants/metadata_spec.py +491 -0
- bead/participants/models.py +276 -0
- bead/resources/__init__.py +29 -0
- bead/resources/adapters/__init__.py +19 -0
- bead/resources/adapters/base.py +104 -0
- bead/resources/adapters/cache.py +128 -0
- bead/resources/adapters/glazing.py +508 -0
- bead/resources/adapters/registry.py +117 -0
- bead/resources/adapters/unimorph.py +796 -0
- bead/resources/classification.py +856 -0
- bead/resources/constraint_builders.py +329 -0
- bead/resources/constraints.py +165 -0
- bead/resources/lexical_item.py +223 -0
- bead/resources/lexicon.py +744 -0
- bead/resources/loaders.py +209 -0
- bead/resources/template.py +441 -0
- bead/resources/template_collection.py +707 -0
- bead/resources/template_generation.py +349 -0
- bead/simulation/__init__.py +29 -0
- bead/simulation/annotators/__init__.py +15 -0
- bead/simulation/annotators/base.py +175 -0
- bead/simulation/annotators/distance_based.py +135 -0
- bead/simulation/annotators/lm_based.py +114 -0
- bead/simulation/annotators/oracle.py +182 -0
- bead/simulation/annotators/random.py +181 -0
- bead/simulation/dsl_extension/__init__.py +3 -0
- bead/simulation/noise_models/__init__.py +13 -0
- bead/simulation/noise_models/base.py +42 -0
- bead/simulation/noise_models/random_noise.py +82 -0
- bead/simulation/noise_models/systematic.py +132 -0
- bead/simulation/noise_models/temperature.py +86 -0
- bead/simulation/runner.py +144 -0
- bead/simulation/strategies/__init__.py +23 -0
- bead/simulation/strategies/base.py +123 -0
- bead/simulation/strategies/binary.py +103 -0
- bead/simulation/strategies/categorical.py +123 -0
- bead/simulation/strategies/cloze.py +224 -0
- bead/simulation/strategies/forced_choice.py +127 -0
- bead/simulation/strategies/free_text.py +105 -0
- bead/simulation/strategies/magnitude.py +116 -0
- bead/simulation/strategies/multi_select.py +129 -0
- bead/simulation/strategies/ordinal_scale.py +131 -0
- bead/templates/__init__.py +27 -0
- bead/templates/adapters/__init__.py +17 -0
- bead/templates/adapters/base.py +128 -0
- bead/templates/adapters/cache.py +178 -0
- bead/templates/adapters/huggingface.py +312 -0
- bead/templates/combinatorics.py +103 -0
- bead/templates/filler.py +605 -0
- bead/templates/renderers.py +177 -0
- bead/templates/resolver.py +178 -0
- bead/templates/strategies.py +1806 -0
- bead/templates/streaming.py +195 -0
- bead-0.1.0.dist-info/METADATA +212 -0
- bead-0.1.0.dist-info/RECORD +231 -0
- bead-0.1.0.dist-info/WHEEL +4 -0
- bead-0.1.0.dist-info/entry_points.txt +2 -0
- bead-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List distribution system for JATOS batch sessions.
|
|
3
|
+
*
|
|
4
|
+
* Manages server-side list assignment using JATOS batch sessions.
|
|
5
|
+
* Supports 8 distribution strategies with strict error handling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BatchSessionData, JatosAPI, JatosPromise } from "../types/jatos.js";
|
|
9
|
+
|
|
10
|
+
declare const jatos: JatosAPI;
|
|
11
|
+
|
|
12
|
+
// Strategy types
|
|
13
|
+
export type StrategyType =
|
|
14
|
+
| "random"
|
|
15
|
+
| "sequential"
|
|
16
|
+
| "balanced"
|
|
17
|
+
| "latin_square"
|
|
18
|
+
| "stratified"
|
|
19
|
+
| "weighted_random"
|
|
20
|
+
| "quota_based"
|
|
21
|
+
| "metadata_based";
|
|
22
|
+
|
|
23
|
+
// Distribution configuration
|
|
24
|
+
export interface DistributionConfig {
|
|
25
|
+
strategy_type: StrategyType;
|
|
26
|
+
strategy_config?: StrategyConfig;
|
|
27
|
+
max_participants?: number;
|
|
28
|
+
debug_mode?: boolean;
|
|
29
|
+
debug_list_index?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StrategyConfig {
|
|
33
|
+
factors?: string[];
|
|
34
|
+
weight_expression?: string;
|
|
35
|
+
normalize_weights?: boolean;
|
|
36
|
+
participants_per_list?: number;
|
|
37
|
+
allow_overflow?: boolean;
|
|
38
|
+
filter_expression?: string;
|
|
39
|
+
rank_expression?: string;
|
|
40
|
+
rank_ascending?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Experiment list structure
|
|
44
|
+
export interface ExperimentList {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
list_number: number;
|
|
48
|
+
list_metadata?: Record<string, unknown>;
|
|
49
|
+
item_refs: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Queue entry for list assignments
|
|
53
|
+
interface QueueEntry {
|
|
54
|
+
list_index: number;
|
|
55
|
+
list_id: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Assignment record
|
|
59
|
+
interface Assignment {
|
|
60
|
+
list_index: number;
|
|
61
|
+
list_id: string;
|
|
62
|
+
assigned_at: string;
|
|
63
|
+
completed: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Statistics structure
|
|
67
|
+
interface Statistics {
|
|
68
|
+
assignment_counts: Record<number, number>;
|
|
69
|
+
completion_counts: Record<number, number>;
|
|
70
|
+
total_assignments: number;
|
|
71
|
+
total_completions: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Item structure
|
|
75
|
+
export interface ExperimentItem {
|
|
76
|
+
id: string;
|
|
77
|
+
[key: string]: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Sleep for specified milliseconds.
|
|
82
|
+
*/
|
|
83
|
+
function sleep(ms: number): Promise<void> {
|
|
84
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Load lists from lists.jsonl file.
|
|
89
|
+
*/
|
|
90
|
+
export async function loadLists(jsonlPath: string): Promise<ExperimentList[]> {
|
|
91
|
+
const response = await fetch(jsonlPath);
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Failed to fetch lists.jsonl (HTTP ${response.status}). Expected file at: ${jsonlPath}. Verify the experiment was generated correctly using JsPsychExperimentGenerator.generate().`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const text = await response.text();
|
|
99
|
+
const lists: ExperimentList[] = [];
|
|
100
|
+
const lines = text.trim().split("\n");
|
|
101
|
+
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
if (line.trim()) {
|
|
104
|
+
try {
|
|
105
|
+
const list = JSON.parse(line) as ExperimentList;
|
|
106
|
+
lists.push(list);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Failed to parse list from lists.jsonl: ${message}. ` +
|
|
111
|
+
`Line content: ${line.substring(0, 100)}...`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (lists.length === 0) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Loaded lists.jsonl but got empty array. Verify your ExperimentLists were created and passed to generate(). File path: ${jsonlPath}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return lists;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Load items from items.jsonl file.
|
|
128
|
+
*/
|
|
129
|
+
export async function loadItems(jsonlPath: string): Promise<Record<string, ExperimentItem>> {
|
|
130
|
+
const response = await fetch(jsonlPath);
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Failed to fetch items.jsonl (HTTP ${response.status}). Expected file at: ${jsonlPath}. Verify the experiment was generated correctly.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const text = await response.text();
|
|
138
|
+
const items: Record<string, ExperimentItem> = {};
|
|
139
|
+
const lines = text.trim().split("\n");
|
|
140
|
+
|
|
141
|
+
for (const line of lines) {
|
|
142
|
+
if (line.trim()) {
|
|
143
|
+
try {
|
|
144
|
+
const item = JSON.parse(line) as ExperimentItem;
|
|
145
|
+
items[item.id] = item;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Failed to parse item from items.jsonl: ${message}. ` +
|
|
150
|
+
`Line content: ${line.substring(0, 100)}...`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (Object.keys(items).length === 0) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Loaded items.jsonl but got empty dictionary. Verify your Items were created and passed to generate(). File path: ${jsonlPath}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return items;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Generate balanced Latin square using Bradley's (1958) algorithm.
|
|
167
|
+
*/
|
|
168
|
+
function generateBalancedLatinSquare(n: number): number[][] {
|
|
169
|
+
const square: number[][] = [];
|
|
170
|
+
for (let i = 0; i < n; i++) {
|
|
171
|
+
const row: number[] = [];
|
|
172
|
+
for (let j = 0; j < n; j++) {
|
|
173
|
+
if (i % 2 === 0) {
|
|
174
|
+
row.push((Math.floor(i / 2) + j) % n);
|
|
175
|
+
} else {
|
|
176
|
+
row.push((Math.floor(i / 2) + n - j) % n);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
square.push(row);
|
|
180
|
+
}
|
|
181
|
+
return square;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Fisher-Yates shuffle algorithm for array randomization.
|
|
186
|
+
*/
|
|
187
|
+
function shuffleArray<T>(array: T[]): void {
|
|
188
|
+
for (let i = array.length - 1; i > 0; i--) {
|
|
189
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
190
|
+
const temp = array[i];
|
|
191
|
+
const swapVal = array[j];
|
|
192
|
+
if (temp !== undefined && swapVal !== undefined) {
|
|
193
|
+
array[i] = swapVal;
|
|
194
|
+
array[j] = temp;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Initialize queue for random strategy.
|
|
201
|
+
*/
|
|
202
|
+
function initializeRandom(
|
|
203
|
+
_config: DistributionConfig,
|
|
204
|
+
lists: ExperimentList[],
|
|
205
|
+
maxParticipants: number,
|
|
206
|
+
): QueueEntry[] {
|
|
207
|
+
const queue: QueueEntry[] = [];
|
|
208
|
+
const perList = Math.ceil(maxParticipants / lists.length);
|
|
209
|
+
for (let i = 0; i < lists.length; i++) {
|
|
210
|
+
const list = lists[i];
|
|
211
|
+
if (list) {
|
|
212
|
+
for (let j = 0; j < perList; j++) {
|
|
213
|
+
queue.push({ list_index: i, list_id: list.id });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
shuffleArray(queue);
|
|
218
|
+
return queue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Initialize queue for sequential strategy.
|
|
223
|
+
*/
|
|
224
|
+
function initializeSequential(
|
|
225
|
+
_config: DistributionConfig,
|
|
226
|
+
lists: ExperimentList[],
|
|
227
|
+
maxParticipants: number,
|
|
228
|
+
): QueueEntry[] {
|
|
229
|
+
const queue: QueueEntry[] = [];
|
|
230
|
+
for (let i = 0; i < maxParticipants; i++) {
|
|
231
|
+
const listIndex = i % lists.length;
|
|
232
|
+
const list = lists[listIndex];
|
|
233
|
+
if (list) {
|
|
234
|
+
queue.push({ list_index: listIndex, list_id: list.id });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return queue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Initialize queue and matrix for Latin square strategy.
|
|
242
|
+
*/
|
|
243
|
+
function initializeLatinSquare(
|
|
244
|
+
_config: DistributionConfig,
|
|
245
|
+
lists: ExperimentList[],
|
|
246
|
+
): { queue: QueueEntry[]; matrix: number[][] } {
|
|
247
|
+
const matrix = generateBalancedLatinSquare(lists.length);
|
|
248
|
+
const queue: QueueEntry[] = [];
|
|
249
|
+
|
|
250
|
+
for (let row = 0; row < matrix.length; row++) {
|
|
251
|
+
const matrixRow = matrix[row];
|
|
252
|
+
if (matrixRow) {
|
|
253
|
+
for (let col = 0; col < matrixRow.length; col++) {
|
|
254
|
+
const listIndex = matrixRow[col];
|
|
255
|
+
if (listIndex !== undefined) {
|
|
256
|
+
const list = lists[listIndex];
|
|
257
|
+
if (list) {
|
|
258
|
+
queue.push({ list_index: listIndex, list_id: list.id });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { queue, matrix };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Initialize queue and quotas for quota-based strategy.
|
|
270
|
+
*/
|
|
271
|
+
function initializeQuotaBased(
|
|
272
|
+
config: DistributionConfig,
|
|
273
|
+
lists: ExperimentList[],
|
|
274
|
+
): { queue: QueueEntry[]; quotas: Record<number, number> } {
|
|
275
|
+
const quota = config.strategy_config?.participants_per_list ?? 10;
|
|
276
|
+
const quotas: Record<number, number> = {};
|
|
277
|
+
const queue: QueueEntry[] = [];
|
|
278
|
+
|
|
279
|
+
for (let i = 0; i < lists.length; i++) {
|
|
280
|
+
quotas[i] = quota;
|
|
281
|
+
const list = lists[i];
|
|
282
|
+
if (list) {
|
|
283
|
+
for (let j = 0; j < quota; j++) {
|
|
284
|
+
queue.push({ list_index: i, list_id: list.id });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
shuffleArray(queue);
|
|
290
|
+
return { queue, quotas };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Initialize batch session state for list distribution.
|
|
295
|
+
*/
|
|
296
|
+
async function initializeBatchSession(
|
|
297
|
+
config: DistributionConfig,
|
|
298
|
+
lists: ExperimentList[],
|
|
299
|
+
): Promise<void> {
|
|
300
|
+
// set distribution config
|
|
301
|
+
await jatos.batchSession.set("distribution", {
|
|
302
|
+
strategy_type: config.strategy_type,
|
|
303
|
+
strategy_config: config.strategy_config ?? {},
|
|
304
|
+
initialized: true,
|
|
305
|
+
created_at: new Date().toISOString(),
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// initialize statistics
|
|
309
|
+
const assignment_counts: Record<number, number> = {};
|
|
310
|
+
const completion_counts: Record<number, number> = {};
|
|
311
|
+
for (let i = 0; i < lists.length; i++) {
|
|
312
|
+
assignment_counts[i] = 0;
|
|
313
|
+
completion_counts[i] = 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await jatos.batchSession.set("statistics", {
|
|
317
|
+
assignment_counts,
|
|
318
|
+
completion_counts,
|
|
319
|
+
total_assignments: 0,
|
|
320
|
+
total_completions: 0,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// initialize assignments
|
|
324
|
+
await jatos.batchSession.set("assignments", {});
|
|
325
|
+
|
|
326
|
+
// strategy-specific initialization
|
|
327
|
+
const maxParticipants = config.max_participants ?? 1000;
|
|
328
|
+
|
|
329
|
+
switch (config.strategy_type) {
|
|
330
|
+
case "random": {
|
|
331
|
+
const randomQueue = initializeRandom(config, lists, maxParticipants);
|
|
332
|
+
await jatos.batchSession.set("lists_queue", randomQueue);
|
|
333
|
+
await jatos.batchSession.set("strategy_state", {});
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
case "sequential": {
|
|
338
|
+
const seqQueue = initializeSequential(config, lists, maxParticipants);
|
|
339
|
+
await jatos.batchSession.set("lists_queue", seqQueue);
|
|
340
|
+
await jatos.batchSession.set("strategy_state", { next_index: 0 });
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
case "balanced": {
|
|
345
|
+
await jatos.batchSession.set("strategy_state", {});
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
case "latin_square": {
|
|
350
|
+
const { queue, matrix } = initializeLatinSquare(config, lists);
|
|
351
|
+
await jatos.batchSession.set("lists_queue", queue);
|
|
352
|
+
await jatos.batchSession.set("strategy_state", {
|
|
353
|
+
latin_square_matrix: matrix,
|
|
354
|
+
latin_square_position: 0,
|
|
355
|
+
});
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
case "stratified": {
|
|
360
|
+
if (!config.strategy_config?.factors || config.strategy_config.factors.length === 0) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`StratifiedConfig requires 'factors' in strategy_config. Got: ${JSON.stringify(config.strategy_config)}. Provide a list like ['condition', 'verb_type'].`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
await jatos.batchSession.set("strategy_state", {});
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
case "weighted_random": {
|
|
370
|
+
if (!config.strategy_config?.weight_expression) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`WeightedRandomConfig requires 'weight_expression' in strategy_config. Got: ${JSON.stringify(config.strategy_config)}. Provide a JavaScript expression like 'list_metadata.priority || 1.0'.`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
await jatos.batchSession.set("strategy_state", {});
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
case "quota_based": {
|
|
380
|
+
if (!config.strategy_config?.participants_per_list) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`QuotaConfig requires 'participants_per_list' in strategy_config. Got: ${JSON.stringify(config.strategy_config)}. Add 'participants_per_list: <int>' to your distribution_strategy config.`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
const { queue: quotaQueue, quotas } = initializeQuotaBased(config, lists);
|
|
386
|
+
await jatos.batchSession.set("lists_queue", quotaQueue);
|
|
387
|
+
await jatos.batchSession.set("strategy_state", { remaining_quotas: quotas });
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
case "metadata_based": {
|
|
392
|
+
const hasFilter = config.strategy_config?.filter_expression;
|
|
393
|
+
const hasRank = config.strategy_config?.rank_expression;
|
|
394
|
+
if (!hasFilter && !hasRank) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`MetadataBasedConfig requires at least one of 'filter_expression' or 'rank_expression'. Got: ${JSON.stringify(config.strategy_config)}. Add 'filter_expression' or 'rank_expression'.`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
await jatos.batchSession.set("strategy_state", {});
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
default: {
|
|
404
|
+
const _exhaustive: never = config.strategy_type;
|
|
405
|
+
throw new Error(`Unknown strategy type: '${config.strategy_type}'.`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Atomic queue update with retry using promise callbacks.
|
|
412
|
+
*/
|
|
413
|
+
function updateQueueAtomically(
|
|
414
|
+
workerId: string,
|
|
415
|
+
selected: QueueEntry,
|
|
416
|
+
updatedQueue: QueueEntry[],
|
|
417
|
+
lists: ExperimentList[],
|
|
418
|
+
): Promise<number> {
|
|
419
|
+
return new Promise((resolve, reject) => {
|
|
420
|
+
function attemptUpdate(retries = 5): void {
|
|
421
|
+
const currentQueue =
|
|
422
|
+
(jatos.batchSession.get("lists_queue") as QueueEntry[] | undefined) ?? [];
|
|
423
|
+
|
|
424
|
+
if (Math.abs(currentQueue.length - updatedQueue.length) > 1) {
|
|
425
|
+
if (retries > 0) {
|
|
426
|
+
setTimeout(() => attemptUpdate(retries - 1), 100 * (6 - retries));
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
reject(new Error("Queue modified concurrently"));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
(jatos.batchSession.set("lists_queue", updatedQueue) as JatosPromise<void>)
|
|
434
|
+
.then(() => {
|
|
435
|
+
const assignments =
|
|
436
|
+
(jatos.batchSession.get("assignments") as Record<string, Assignment>) ?? {};
|
|
437
|
+
assignments[workerId] = {
|
|
438
|
+
list_index: selected.list_index,
|
|
439
|
+
list_id: selected.list_id,
|
|
440
|
+
assigned_at: new Date().toISOString(),
|
|
441
|
+
completed: false,
|
|
442
|
+
};
|
|
443
|
+
return jatos.batchSession.set("assignments", assignments);
|
|
444
|
+
})
|
|
445
|
+
.then(() => {
|
|
446
|
+
const stats = (jatos.batchSession.get("statistics") as Statistics | undefined) ?? {
|
|
447
|
+
assignment_counts: {},
|
|
448
|
+
completion_counts: {},
|
|
449
|
+
total_assignments: 0,
|
|
450
|
+
total_completions: 0,
|
|
451
|
+
};
|
|
452
|
+
const currentCount = stats.assignment_counts[selected.list_index] ?? 0;
|
|
453
|
+
stats.assignment_counts[selected.list_index] = currentCount + 1;
|
|
454
|
+
stats.total_assignments += 1;
|
|
455
|
+
return jatos.batchSession.set("statistics", stats);
|
|
456
|
+
})
|
|
457
|
+
.then(() => resolve(selected.list_index))
|
|
458
|
+
.fail((error: Error) => {
|
|
459
|
+
if (retries > 0) {
|
|
460
|
+
setTimeout(() => attemptUpdate(retries - 1), 100 * (6 - retries));
|
|
461
|
+
} else {
|
|
462
|
+
reject(new Error(`Failed to update queue: ${error.message}`));
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
attemptUpdate();
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Atomic statistics update with version checking.
|
|
473
|
+
*/
|
|
474
|
+
function updateStatisticsAtomically(
|
|
475
|
+
workerId: string,
|
|
476
|
+
listIndex: number,
|
|
477
|
+
oldCounts: Record<number, number>,
|
|
478
|
+
_oldStats: Statistics,
|
|
479
|
+
lists: ExperimentList[],
|
|
480
|
+
): Promise<number> {
|
|
481
|
+
return new Promise((resolve, reject) => {
|
|
482
|
+
function attemptUpdate(retries = 5): void {
|
|
483
|
+
const currentStats = (jatos.batchSession.get("statistics") as Statistics | undefined) ?? {
|
|
484
|
+
assignment_counts: {},
|
|
485
|
+
completion_counts: {},
|
|
486
|
+
total_assignments: 0,
|
|
487
|
+
total_completions: 0,
|
|
488
|
+
};
|
|
489
|
+
const currentCounts = currentStats.assignment_counts;
|
|
490
|
+
|
|
491
|
+
const expectedCount = oldCounts[listIndex] ?? 0;
|
|
492
|
+
const actualCount = currentCounts[listIndex] ?? 0;
|
|
493
|
+
|
|
494
|
+
if (actualCount !== expectedCount && retries > 0) {
|
|
495
|
+
setTimeout(
|
|
496
|
+
() => {
|
|
497
|
+
updateStatisticsAtomically(workerId, listIndex, currentCounts, currentStats, lists)
|
|
498
|
+
.then(resolve)
|
|
499
|
+
.catch(reject);
|
|
500
|
+
},
|
|
501
|
+
100 * (6 - retries),
|
|
502
|
+
);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
currentStats.assignment_counts[listIndex] =
|
|
507
|
+
(currentStats.assignment_counts[listIndex] ?? 0) + 1;
|
|
508
|
+
currentStats.total_assignments = (currentStats.total_assignments ?? 0) + 1;
|
|
509
|
+
|
|
510
|
+
(jatos.batchSession.set("statistics", currentStats) as JatosPromise<void>)
|
|
511
|
+
.then(() => {
|
|
512
|
+
const assignments =
|
|
513
|
+
(jatos.batchSession.get("assignments") as Record<string, Assignment>) ?? {};
|
|
514
|
+
const list = lists[listIndex];
|
|
515
|
+
assignments[workerId] = {
|
|
516
|
+
list_index: listIndex,
|
|
517
|
+
list_id: list?.id ?? "",
|
|
518
|
+
assigned_at: new Date().toISOString(),
|
|
519
|
+
completed: false,
|
|
520
|
+
};
|
|
521
|
+
return jatos.batchSession.set("assignments", assignments);
|
|
522
|
+
})
|
|
523
|
+
.then(() => resolve(listIndex))
|
|
524
|
+
.fail((error: Error) => {
|
|
525
|
+
if (retries > 0) {
|
|
526
|
+
setTimeout(() => attemptUpdate(retries - 1), 100 * (6 - retries));
|
|
527
|
+
} else {
|
|
528
|
+
reject(new Error(`Failed to update statistics: ${error.message}`));
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
attemptUpdate();
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Unified assignment function routing to strategy-specific implementations.
|
|
539
|
+
*/
|
|
540
|
+
async function assignList(
|
|
541
|
+
workerId: string,
|
|
542
|
+
config: DistributionConfig,
|
|
543
|
+
lists: ExperimentList[],
|
|
544
|
+
): Promise<number> {
|
|
545
|
+
// check existing assignment (idempotency)
|
|
546
|
+
const assignments = (jatos.batchSession.get("assignments") as Record<string, Assignment>) ?? {};
|
|
547
|
+
const existing = assignments[workerId];
|
|
548
|
+
if (existing) {
|
|
549
|
+
console.log("Worker already assigned:", existing);
|
|
550
|
+
return existing.list_index;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// route to strategy-specific assignment
|
|
554
|
+
switch (config.strategy_type) {
|
|
555
|
+
case "random":
|
|
556
|
+
return assignRandom(workerId, config, lists);
|
|
557
|
+
case "sequential":
|
|
558
|
+
return assignSequential(workerId, config, lists);
|
|
559
|
+
case "balanced":
|
|
560
|
+
return assignBalanced(workerId, config, lists);
|
|
561
|
+
case "latin_square":
|
|
562
|
+
return assignLatinSquare(workerId, config, lists);
|
|
563
|
+
case "stratified":
|
|
564
|
+
return assignStratified(workerId, config, lists);
|
|
565
|
+
case "weighted_random":
|
|
566
|
+
return assignWeightedRandom(workerId, config, lists);
|
|
567
|
+
case "quota_based":
|
|
568
|
+
return assignQuotaBased(workerId, config, lists);
|
|
569
|
+
case "metadata_based":
|
|
570
|
+
return assignMetadataBased(workerId, config, lists);
|
|
571
|
+
default: {
|
|
572
|
+
const _exhaustive: never = config.strategy_type;
|
|
573
|
+
throw new Error(`Unknown strategy type: '${config.strategy_type}'.`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Random assignment strategy (queue-based).
|
|
580
|
+
*/
|
|
581
|
+
async function assignRandom(
|
|
582
|
+
workerId: string,
|
|
583
|
+
_config: DistributionConfig,
|
|
584
|
+
lists: ExperimentList[],
|
|
585
|
+
): Promise<number> {
|
|
586
|
+
const queue = (jatos.batchSession.get("lists_queue") as QueueEntry[] | undefined) ?? [];
|
|
587
|
+
if (queue.length === 0) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
"No lists available in queue for random assignment. " +
|
|
590
|
+
"Verify lists.jsonl was generated and batch session initialized.",
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const randomIndex = Math.floor(Math.random() * queue.length);
|
|
595
|
+
const selected = queue[randomIndex];
|
|
596
|
+
if (!selected) {
|
|
597
|
+
throw new Error("Failed to select from queue");
|
|
598
|
+
}
|
|
599
|
+
const updatedQueue = queue.filter((_, idx) => idx !== randomIndex);
|
|
600
|
+
|
|
601
|
+
return updateQueueAtomically(workerId, selected, updatedQueue, lists);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Sequential (round-robin) assignment strategy.
|
|
606
|
+
*/
|
|
607
|
+
async function assignSequential(
|
|
608
|
+
workerId: string,
|
|
609
|
+
_config: DistributionConfig,
|
|
610
|
+
lists: ExperimentList[],
|
|
611
|
+
): Promise<number> {
|
|
612
|
+
const queue = (jatos.batchSession.get("lists_queue") as QueueEntry[] | undefined) ?? [];
|
|
613
|
+
const nextIndex =
|
|
614
|
+
(jatos.batchSession.get("strategy_state/next_index") as number | undefined) ?? 0;
|
|
615
|
+
|
|
616
|
+
if (nextIndex >= queue.length) {
|
|
617
|
+
throw new Error(
|
|
618
|
+
`Sequential queue exhausted (position ${nextIndex} >= queue length ${queue.length}). Increase max_participants or add more lists.`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const selected = queue[nextIndex];
|
|
623
|
+
if (!selected) {
|
|
624
|
+
throw new Error("Failed to select from queue");
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// simplified: just update statistics
|
|
628
|
+
const stats = (jatos.batchSession.get("statistics") as Statistics | undefined) ?? {
|
|
629
|
+
assignment_counts: {},
|
|
630
|
+
completion_counts: {},
|
|
631
|
+
total_assignments: 0,
|
|
632
|
+
total_completions: 0,
|
|
633
|
+
};
|
|
634
|
+
const counts = stats.assignment_counts;
|
|
635
|
+
return updateStatisticsAtomically(workerId, selected.list_index, counts, stats, lists);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Balanced assignment strategy (assign to least-used list).
|
|
640
|
+
*/
|
|
641
|
+
async function assignBalanced(
|
|
642
|
+
workerId: string,
|
|
643
|
+
_config: DistributionConfig,
|
|
644
|
+
lists: ExperimentList[],
|
|
645
|
+
): Promise<number> {
|
|
646
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
647
|
+
const stats = (jatos.batchSession.get("statistics") as Statistics | undefined) ?? {
|
|
648
|
+
assignment_counts: {},
|
|
649
|
+
completion_counts: {},
|
|
650
|
+
total_assignments: 0,
|
|
651
|
+
total_completions: 0,
|
|
652
|
+
};
|
|
653
|
+
const counts = stats.assignment_counts;
|
|
654
|
+
|
|
655
|
+
// find minimum count
|
|
656
|
+
let minCount = Number.POSITIVE_INFINITY;
|
|
657
|
+
const minIndices: number[] = [];
|
|
658
|
+
for (let i = 0; i < lists.length; i++) {
|
|
659
|
+
const count = counts[i] ?? 0;
|
|
660
|
+
if (count < minCount) {
|
|
661
|
+
minCount = count;
|
|
662
|
+
minIndices.length = 0;
|
|
663
|
+
minIndices.push(i);
|
|
664
|
+
} else if (count === minCount) {
|
|
665
|
+
minIndices.push(i);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const selectedIndex = minIndices[Math.floor(Math.random() * minIndices.length)];
|
|
670
|
+
if (selectedIndex === undefined) {
|
|
671
|
+
throw new Error("No lists available");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
try {
|
|
675
|
+
const result = await updateStatisticsAtomically(
|
|
676
|
+
workerId,
|
|
677
|
+
selectedIndex,
|
|
678
|
+
counts,
|
|
679
|
+
stats,
|
|
680
|
+
lists,
|
|
681
|
+
);
|
|
682
|
+
return result;
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (attempt === 4) {
|
|
685
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
686
|
+
throw new Error(`Failed to assign balanced list after 5 retries. Last error: ${message}.`);
|
|
687
|
+
}
|
|
688
|
+
await sleep(100 * 2 ** attempt);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
throw new Error("Failed to assign balanced list after retries");
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Latin square counterbalancing strategy.
|
|
697
|
+
*/
|
|
698
|
+
async function assignLatinSquare(
|
|
699
|
+
workerId: string,
|
|
700
|
+
_config: DistributionConfig,
|
|
701
|
+
lists: ExperimentList[],
|
|
702
|
+
): Promise<number> {
|
|
703
|
+
const matrix = jatos.batchSession.get("strategy_state/latin_square_matrix") as
|
|
704
|
+
| number[][]
|
|
705
|
+
| undefined;
|
|
706
|
+
const position =
|
|
707
|
+
(jatos.batchSession.get("strategy_state/latin_square_position") as number | undefined) ?? 0;
|
|
708
|
+
|
|
709
|
+
if (!matrix || !Array.isArray(matrix) || matrix.length === 0) {
|
|
710
|
+
throw new Error(
|
|
711
|
+
"Latin square matrix not initialized. Verify batch session was initialized correctly.",
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const row = position % matrix.length;
|
|
716
|
+
const matrixRow = matrix[row];
|
|
717
|
+
if (!matrixRow) {
|
|
718
|
+
throw new Error("Invalid matrix row");
|
|
719
|
+
}
|
|
720
|
+
const col = Math.floor(position / matrix.length) % matrixRow.length;
|
|
721
|
+
const listIndex = matrixRow[col];
|
|
722
|
+
if (listIndex === undefined) {
|
|
723
|
+
throw new Error("Invalid list index from matrix");
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const stats = (jatos.batchSession.get("statistics") as Statistics | undefined) ?? {
|
|
727
|
+
assignment_counts: {},
|
|
728
|
+
completion_counts: {},
|
|
729
|
+
total_assignments: 0,
|
|
730
|
+
total_completions: 0,
|
|
731
|
+
};
|
|
732
|
+
const counts = stats.assignment_counts;
|
|
733
|
+
return updateStatisticsAtomically(workerId, listIndex, counts, stats, lists);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Stratified assignment strategy (balance across factors).
|
|
738
|
+
*/
|
|
739
|
+
async function assignStratified(
|
|
740
|
+
workerId: string,
|
|
741
|
+
config: DistributionConfig,
|
|
742
|
+
lists: ExperimentList[],
|
|
743
|
+
): Promise<number> {
|
|
744
|
+
const factors = config.strategy_config?.factors;
|
|
745
|
+
if (!factors || factors.length === 0) {
|
|
746
|
+
throw new Error(
|
|
747
|
+
`StratifiedConfig requires 'factors' in strategy_config. Got: ${JSON.stringify(config.strategy_config)}.`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
752
|
+
const stats = (jatos.batchSession.get("statistics") as Statistics | undefined) ?? {
|
|
753
|
+
assignment_counts: {},
|
|
754
|
+
completion_counts: {},
|
|
755
|
+
total_assignments: 0,
|
|
756
|
+
total_completions: 0,
|
|
757
|
+
};
|
|
758
|
+
const counts = stats.assignment_counts;
|
|
759
|
+
|
|
760
|
+
// group lists by factor combinations
|
|
761
|
+
const strata: Record<string, number[]> = {};
|
|
762
|
+
for (let i = 0; i < lists.length; i++) {
|
|
763
|
+
const list = lists[i];
|
|
764
|
+
if (list) {
|
|
765
|
+
const key = factors
|
|
766
|
+
.map((f) => {
|
|
767
|
+
const metadata = list.list_metadata as Record<string, unknown> | undefined;
|
|
768
|
+
return String(metadata?.[f] ?? "null");
|
|
769
|
+
})
|
|
770
|
+
.join("|");
|
|
771
|
+
if (!strata[key]) {
|
|
772
|
+
strata[key] = [];
|
|
773
|
+
}
|
|
774
|
+
strata[key]?.push(i);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// find stratum with minimum total assignments
|
|
779
|
+
let minCount = Number.POSITIVE_INFINITY;
|
|
780
|
+
let minStratumIndices: number[] = [];
|
|
781
|
+
|
|
782
|
+
for (const [_key, indices] of Object.entries(strata)) {
|
|
783
|
+
const stratumCount = indices.reduce((sum, idx) => sum + (counts[idx] ?? 0), 0);
|
|
784
|
+
if (stratumCount < minCount) {
|
|
785
|
+
minCount = stratumCount;
|
|
786
|
+
minStratumIndices = indices;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const listIndex = minStratumIndices[Math.floor(Math.random() * minStratumIndices.length)];
|
|
791
|
+
if (listIndex === undefined) {
|
|
792
|
+
throw new Error("No lists available in strata");
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
try {
|
|
796
|
+
const result = await updateStatisticsAtomically(workerId, listIndex, counts, stats, lists);
|
|
797
|
+
return result;
|
|
798
|
+
} catch (error) {
|
|
799
|
+
if (attempt === 4) {
|
|
800
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
801
|
+
throw new Error(`Failed to assign stratified list after 5 retries: ${message}`);
|
|
802
|
+
}
|
|
803
|
+
await sleep(100 * 2 ** attempt);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
throw new Error("Failed to assign stratified list after retries");
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Weighted random assignment strategy.
|
|
812
|
+
*/
|
|
813
|
+
async function assignWeightedRandom(
|
|
814
|
+
workerId: string,
|
|
815
|
+
config: DistributionConfig,
|
|
816
|
+
lists: ExperimentList[],
|
|
817
|
+
): Promise<number> {
|
|
818
|
+
const expr = config.strategy_config?.weight_expression;
|
|
819
|
+
if (!expr) {
|
|
820
|
+
throw new Error(`WeightedRandomConfig requires 'weight_expression' in strategy_config.`);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const normalize = config.strategy_config?.normalize_weights !== false;
|
|
824
|
+
|
|
825
|
+
// compute weights from metadata
|
|
826
|
+
const weights = lists.map((list) => {
|
|
827
|
+
const list_metadata = list.list_metadata ?? {};
|
|
828
|
+
try {
|
|
829
|
+
// biome-ignore lint/security/noGlobalEval: user-provided expression for weighted random selection
|
|
830
|
+
return eval(expr) as number;
|
|
831
|
+
} catch (error) {
|
|
832
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
833
|
+
throw new Error(
|
|
834
|
+
`Failed to evaluate weight_expression '${expr}' for list ${list.name}: ${message}.`,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
// normalize if requested
|
|
840
|
+
let w = weights;
|
|
841
|
+
if (normalize) {
|
|
842
|
+
const sum = weights.reduce((a, b) => a + b, 0);
|
|
843
|
+
if (sum === 0) {
|
|
844
|
+
throw new Error("Sum of weights is 0. Cannot normalize.");
|
|
845
|
+
}
|
|
846
|
+
w = weights.map((weight) => weight / sum);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// sample from cumulative distribution
|
|
850
|
+
const cumulative: number[] = [];
|
|
851
|
+
let sum = 0;
|
|
852
|
+
for (const weight of w) {
|
|
853
|
+
sum += weight;
|
|
854
|
+
cumulative.push(sum);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const lastCumulative = cumulative[cumulative.length - 1];
|
|
858
|
+
const random = Math.random() * (lastCumulative ?? 1);
|
|
859
|
+
let listIndex = lists.length - 1;
|
|
860
|
+
for (let i = 0; i < cumulative.length; i++) {
|
|
861
|
+
const cumulativeValue = cumulative[i];
|
|
862
|
+
if (cumulativeValue !== undefined && random <= cumulativeValue) {
|
|
863
|
+
listIndex = i;
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const stats = (jatos.batchSession.get("statistics") as Statistics | undefined) ?? {
|
|
869
|
+
assignment_counts: {},
|
|
870
|
+
completion_counts: {},
|
|
871
|
+
total_assignments: 0,
|
|
872
|
+
total_completions: 0,
|
|
873
|
+
};
|
|
874
|
+
const counts = stats.assignment_counts;
|
|
875
|
+
return updateStatisticsAtomically(workerId, listIndex, counts, stats, lists);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Quota-based assignment strategy.
|
|
880
|
+
*/
|
|
881
|
+
async function assignQuotaBased(
|
|
882
|
+
workerId: string,
|
|
883
|
+
config: DistributionConfig,
|
|
884
|
+
lists: ExperimentList[],
|
|
885
|
+
): Promise<number> {
|
|
886
|
+
if (!config.strategy_config?.participants_per_list) {
|
|
887
|
+
throw new Error(`QuotaConfig requires 'participants_per_list' in strategy_config.`);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const quotas =
|
|
891
|
+
(jatos.batchSession.get("strategy_state/remaining_quotas") as
|
|
892
|
+
| Record<number, number>
|
|
893
|
+
| undefined) ?? {};
|
|
894
|
+
|
|
895
|
+
// find available lists
|
|
896
|
+
const available: number[] = [];
|
|
897
|
+
for (let i = 0; i < lists.length; i++) {
|
|
898
|
+
const quota = quotas[i];
|
|
899
|
+
if (quota !== undefined && quota > 0) {
|
|
900
|
+
available.push(i);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (available.length === 0) {
|
|
905
|
+
if (config.strategy_config.allow_overflow === true) {
|
|
906
|
+
return assignBalanced(workerId, config, lists);
|
|
907
|
+
}
|
|
908
|
+
throw new Error(
|
|
909
|
+
`All lists have reached quota and allow_overflow=false. Current quotas: ${JSON.stringify(quotas)}.`,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const listIndex = available[Math.floor(Math.random() * available.length)];
|
|
914
|
+
if (listIndex === undefined) {
|
|
915
|
+
throw new Error("No lists available");
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const stats = (jatos.batchSession.get("statistics") as Statistics | undefined) ?? {
|
|
919
|
+
assignment_counts: {},
|
|
920
|
+
completion_counts: {},
|
|
921
|
+
total_assignments: 0,
|
|
922
|
+
total_completions: 0,
|
|
923
|
+
};
|
|
924
|
+
const counts = stats.assignment_counts;
|
|
925
|
+
return updateStatisticsAtomically(workerId, listIndex, counts, stats, lists);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Metadata-based assignment strategy.
|
|
930
|
+
*/
|
|
931
|
+
async function assignMetadataBased(
|
|
932
|
+
workerId: string,
|
|
933
|
+
config: DistributionConfig,
|
|
934
|
+
lists: ExperimentList[],
|
|
935
|
+
): Promise<number> {
|
|
936
|
+
const hasFilter = config.strategy_config?.filter_expression;
|
|
937
|
+
const hasRank = config.strategy_config?.rank_expression;
|
|
938
|
+
|
|
939
|
+
if (!hasFilter && !hasRank) {
|
|
940
|
+
throw new Error(
|
|
941
|
+
`MetadataBasedConfig requires at least one of 'filter_expression' or 'rank_expression'.`,
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// filter lists
|
|
946
|
+
let available = lists.map((list, idx) => ({ list, idx, score: 0 }));
|
|
947
|
+
|
|
948
|
+
if (hasFilter) {
|
|
949
|
+
const filterExpr = config.strategy_config?.filter_expression;
|
|
950
|
+
available = available.filter((item) => {
|
|
951
|
+
const list_metadata = item.list.list_metadata ?? {};
|
|
952
|
+
try {
|
|
953
|
+
// biome-ignore lint/security/noGlobalEval: user-provided filter expression
|
|
954
|
+
return eval(filterExpr as string) as boolean;
|
|
955
|
+
} catch (error) {
|
|
956
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
957
|
+
throw new Error(
|
|
958
|
+
`Failed to evaluate filter_expression '${filterExpr}' for list ${item.list.name}: ${message}.`,
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
if (available.length === 0) {
|
|
964
|
+
throw new Error(
|
|
965
|
+
`No lists match filter_expression: '${filterExpr}'. ` +
|
|
966
|
+
`All ${lists.length} lists were filtered out.`,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// rank lists
|
|
972
|
+
if (hasRank) {
|
|
973
|
+
const rankExpr = config.strategy_config?.rank_expression;
|
|
974
|
+
const ascending = config.strategy_config?.rank_ascending !== false;
|
|
975
|
+
|
|
976
|
+
available = available.map((item) => {
|
|
977
|
+
const list_metadata = item.list.list_metadata ?? {};
|
|
978
|
+
let score: number;
|
|
979
|
+
try {
|
|
980
|
+
// biome-ignore lint/security/noGlobalEval: user-provided rank expression
|
|
981
|
+
score = eval(rankExpr as string) as number;
|
|
982
|
+
} catch (error) {
|
|
983
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
984
|
+
throw new Error(
|
|
985
|
+
`Failed to evaluate rank_expression '${rankExpr}' for list ${item.list.name}: ${message}.`,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
return { ...item, score };
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
available.sort((a, b) => (ascending ? a.score - b.score : b.score - a.score));
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const first = available[0];
|
|
995
|
+
if (!first) {
|
|
996
|
+
throw new Error("No lists available after filtering");
|
|
997
|
+
}
|
|
998
|
+
const listIndex = first.idx;
|
|
999
|
+
|
|
1000
|
+
const stats = (jatos.batchSession.get("statistics") as Statistics | undefined) ?? {
|
|
1001
|
+
assignment_counts: {},
|
|
1002
|
+
completion_counts: {},
|
|
1003
|
+
total_assignments: 0,
|
|
1004
|
+
total_completions: 0,
|
|
1005
|
+
};
|
|
1006
|
+
const counts = stats.assignment_counts;
|
|
1007
|
+
return updateStatisticsAtomically(workerId, listIndex, counts, stats, lists);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Mark participant as completed.
|
|
1012
|
+
*/
|
|
1013
|
+
async function markCompletedInternal(workerId: string): Promise<void> {
|
|
1014
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
1015
|
+
try {
|
|
1016
|
+
const allAssignments =
|
|
1017
|
+
(jatos.batchSession.get("assignments") as Record<string, Assignment>) ?? {};
|
|
1018
|
+
const assignment = allAssignments[workerId];
|
|
1019
|
+
|
|
1020
|
+
if (!assignment) {
|
|
1021
|
+
console.warn("No assignment found for worker:", workerId);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (assignment.completed) {
|
|
1026
|
+
console.log("Worker already marked as completed:", workerId);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
assignment.completed = true;
|
|
1031
|
+
allAssignments[workerId] = assignment;
|
|
1032
|
+
await jatos.batchSession.set("assignments", allAssignments);
|
|
1033
|
+
|
|
1034
|
+
const stats = (jatos.batchSession.get("statistics") as Statistics | undefined) ?? {
|
|
1035
|
+
assignment_counts: {},
|
|
1036
|
+
completion_counts: {},
|
|
1037
|
+
total_assignments: 0,
|
|
1038
|
+
total_completions: 0,
|
|
1039
|
+
};
|
|
1040
|
+
const currentCount = stats.completion_counts[assignment.list_index] ?? 0;
|
|
1041
|
+
stats.completion_counts[assignment.list_index] = currentCount + 1;
|
|
1042
|
+
stats.total_completions = (stats.total_completions ?? 0) + 1;
|
|
1043
|
+
await jatos.batchSession.set("statistics", stats);
|
|
1044
|
+
|
|
1045
|
+
return;
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
if (attempt === 4) {
|
|
1048
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1049
|
+
throw new Error(
|
|
1050
|
+
`Failed to mark worker ${workerId} as completed after 5 retries: ${message}.`,
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
await sleep(100 * 2 ** attempt);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* ListDistributor class for managing list distribution.
|
|
1060
|
+
*/
|
|
1061
|
+
export class ListDistributor {
|
|
1062
|
+
private config: DistributionConfig;
|
|
1063
|
+
private lists: ExperimentList[];
|
|
1064
|
+
private workerId: string | null = null;
|
|
1065
|
+
private assignedListIndex: number | null = null;
|
|
1066
|
+
|
|
1067
|
+
constructor(config: DistributionConfig, lists: ExperimentList[]) {
|
|
1068
|
+
if (!config) {
|
|
1069
|
+
throw new Error(
|
|
1070
|
+
"ListDistributor requires config parameter. " +
|
|
1071
|
+
"Pass the distribution_strategy from your config.",
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (!lists || lists.length === 0) {
|
|
1076
|
+
throw new Error(
|
|
1077
|
+
"ListDistributor requires non-empty lists array. " +
|
|
1078
|
+
"Verify lists.jsonl was loaded correctly.",
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
this.config = config;
|
|
1083
|
+
this.lists = lists;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Initialize distributor and assign list to current worker.
|
|
1088
|
+
*/
|
|
1089
|
+
async initialize(): Promise<number> {
|
|
1090
|
+
if (!this.lists || this.lists.length === 0) {
|
|
1091
|
+
throw new Error(
|
|
1092
|
+
"Cannot initialize: no lists available. " +
|
|
1093
|
+
"Verify lists.jsonl was loaded correctly and contains at least one list.",
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
this.workerId = jatos.workerId;
|
|
1098
|
+
|
|
1099
|
+
if (!this.workerId) {
|
|
1100
|
+
throw new Error(
|
|
1101
|
+
"JATOS workerId not available. " +
|
|
1102
|
+
"This experiment requires JATOS. " +
|
|
1103
|
+
"Ensure you are running this through JATOS, not as a standalone file.",
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (!this.config.strategy_type) {
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
"Invalid distribution config: missing strategy_type. " +
|
|
1110
|
+
"Verify distribution.json was loaded correctly.",
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
try {
|
|
1115
|
+
await this._initializeBatchSession();
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1118
|
+
throw new Error(`Failed to initialize batch session: ${message}.`);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// debug mode: always return same list
|
|
1122
|
+
if (this.config.debug_mode) {
|
|
1123
|
+
const debugIndex = this.config.debug_list_index ?? 0;
|
|
1124
|
+
if (debugIndex < 0 || debugIndex >= this.lists.length) {
|
|
1125
|
+
throw new Error(
|
|
1126
|
+
`Invalid debug_list_index: ${debugIndex}. ` +
|
|
1127
|
+
`Must be between 0 and ${this.lists.length - 1}.`,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
console.log("Debug mode: assigning list", debugIndex);
|
|
1131
|
+
this.assignedListIndex = debugIndex;
|
|
1132
|
+
return this.assignedListIndex;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
try {
|
|
1136
|
+
this.assignedListIndex = await assignList(this.workerId, this.config, this.lists);
|
|
1137
|
+
console.log(`Assigned worker ${this.workerId} to list ${this.assignedListIndex}`);
|
|
1138
|
+
return this.assignedListIndex;
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1141
|
+
throw new Error(
|
|
1142
|
+
`Failed to assign list: ${message}. ` +
|
|
1143
|
+
`Worker ID: ${this.workerId}, Strategy: ${this.config.strategy_type}.`,
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Get the assigned list object.
|
|
1150
|
+
*/
|
|
1151
|
+
getAssignedList(): ExperimentList {
|
|
1152
|
+
if (this.assignedListIndex === null) {
|
|
1153
|
+
throw new Error("List not yet assigned. Call initialize() first.");
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
if (this.assignedListIndex >= this.lists.length) {
|
|
1157
|
+
throw new Error(
|
|
1158
|
+
`Assigned list index ${this.assignedListIndex} out of bounds. ` +
|
|
1159
|
+
`Only ${this.lists.length} lists available.`,
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const list = this.lists[this.assignedListIndex];
|
|
1164
|
+
if (!list) {
|
|
1165
|
+
throw new Error(`List at index ${this.assignedListIndex} is undefined.`);
|
|
1166
|
+
}
|
|
1167
|
+
return list;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Mark current participant as completed.
|
|
1172
|
+
*/
|
|
1173
|
+
async markCompleted(): Promise<void> {
|
|
1174
|
+
if (this.workerId === null || this.assignedListIndex === null) {
|
|
1175
|
+
console.warn("Cannot mark completed: not initialized");
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
try {
|
|
1180
|
+
await markCompletedInternal(this.workerId);
|
|
1181
|
+
console.log(`Marked worker ${this.workerId} as completed`);
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1184
|
+
throw new Error(`Failed to mark worker ${this.workerId} as completed: ${message}.`);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Get current distribution statistics.
|
|
1190
|
+
*/
|
|
1191
|
+
getStatistics(): Statistics | undefined {
|
|
1192
|
+
return jatos.batchSession.get("statistics") as Statistics | undefined;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Initialize batch session with lock mechanism.
|
|
1197
|
+
*/
|
|
1198
|
+
private async _initializeBatchSession(): Promise<void> {
|
|
1199
|
+
if (jatos.batchSession.defined("distribution/initialized")) {
|
|
1200
|
+
console.log("Batch session already initialized");
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
console.log("Initializing batch session...");
|
|
1205
|
+
|
|
1206
|
+
const lockAcquired = await this._acquireLock("init_lock");
|
|
1207
|
+
|
|
1208
|
+
if (!lockAcquired) {
|
|
1209
|
+
await this._waitForInitialization();
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
try {
|
|
1214
|
+
if (jatos.batchSession.defined("distribution/initialized")) {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
await initializeBatchSession(this.config, this.lists);
|
|
1219
|
+
console.log("Batch session initialized");
|
|
1220
|
+
} finally {
|
|
1221
|
+
await this._releaseLock("init_lock");
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Acquire initialization lock.
|
|
1227
|
+
*/
|
|
1228
|
+
private async _acquireLock(lockName: string, timeout = 5000): Promise<boolean> {
|
|
1229
|
+
const startTime = Date.now();
|
|
1230
|
+
|
|
1231
|
+
while (Date.now() - startTime < timeout) {
|
|
1232
|
+
try {
|
|
1233
|
+
const lockValue = jatos.batchSession.get(lockName);
|
|
1234
|
+
|
|
1235
|
+
if (!lockValue) {
|
|
1236
|
+
await jatos.batchSession.set(lockName, {
|
|
1237
|
+
holder: this.workerId,
|
|
1238
|
+
acquired_at: new Date().toISOString(),
|
|
1239
|
+
});
|
|
1240
|
+
return true;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
await sleep(100);
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
console.warn("Error acquiring lock:", error);
|
|
1246
|
+
await sleep(100);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
console.warn(`Failed to acquire lock '${lockName}' within ${timeout}ms`);
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Release initialization lock.
|
|
1256
|
+
*/
|
|
1257
|
+
private async _releaseLock(lockName: string): Promise<void> {
|
|
1258
|
+
try {
|
|
1259
|
+
await jatos.batchSession.remove(lockName);
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
console.error("Error releasing lock:", error);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Wait for initialization to complete.
|
|
1267
|
+
*/
|
|
1268
|
+
private async _waitForInitialization(timeout = 10000): Promise<void> {
|
|
1269
|
+
const startTime = Date.now();
|
|
1270
|
+
|
|
1271
|
+
while (Date.now() - startTime < timeout) {
|
|
1272
|
+
if (jatos.batchSession.defined("distribution/initialized")) {
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
await sleep(200);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
throw new Error(
|
|
1279
|
+
`Batch session initialization timeout (${timeout}ms). This may indicate network issues or JATOS server problems.`,
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
}
|