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,598 @@
|
|
|
1
|
+
"""jsPsych batch experiment generator.
|
|
2
|
+
|
|
3
|
+
Generates complete jsPsych 8.x experiments using JATOS batch sessions for
|
|
4
|
+
server-side list distribution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
from jinja2 import Environment, FileSystemLoader
|
|
14
|
+
|
|
15
|
+
from bead.data.base import JsonValue
|
|
16
|
+
from bead.data.serialization import SerializationError, write_jsonlines
|
|
17
|
+
from bead.deployment.jspsych.config import (
|
|
18
|
+
ChoiceConfig,
|
|
19
|
+
ExperimentConfig,
|
|
20
|
+
InstructionsConfig,
|
|
21
|
+
RatingScaleConfig,
|
|
22
|
+
)
|
|
23
|
+
from bead.deployment.jspsych.trials import create_trial
|
|
24
|
+
from bead.items.item import Item
|
|
25
|
+
from bead.items.item_template import ItemTemplate
|
|
26
|
+
from bead.lists import ExperimentList
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JsPsychExperimentGenerator:
|
|
30
|
+
"""Generator for jsPsych 8.x experiments.
|
|
31
|
+
|
|
32
|
+
This class orchestrates the generation of complete jsPsych experiments,
|
|
33
|
+
including HTML, CSS, JavaScript, and data files. It converts bead's
|
|
34
|
+
ExperimentList and Item models into a deployable jsPsych experiment.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
config : ExperimentConfig
|
|
39
|
+
Experiment configuration.
|
|
40
|
+
output_dir : Path
|
|
41
|
+
Output directory for generated files.
|
|
42
|
+
rating_config : RatingScaleConfig | None
|
|
43
|
+
Configuration for rating scale trials (required for rating experiments).
|
|
44
|
+
Defaults to RatingScaleConfig() if not provided.
|
|
45
|
+
choice_config : ChoiceConfig | None
|
|
46
|
+
Configuration for choice trials (required for choice experiments).
|
|
47
|
+
Defaults to ChoiceConfig() if not provided.
|
|
48
|
+
|
|
49
|
+
Attributes
|
|
50
|
+
----------
|
|
51
|
+
config : ExperimentConfig
|
|
52
|
+
Experiment configuration.
|
|
53
|
+
output_dir : Path
|
|
54
|
+
Output directory for generated files.
|
|
55
|
+
rating_config : RatingScaleConfig
|
|
56
|
+
Configuration for rating scale trials.
|
|
57
|
+
choice_config : ChoiceConfig
|
|
58
|
+
Configuration for choice trials.
|
|
59
|
+
jinja_env : Environment
|
|
60
|
+
Jinja2 environment for template rendering.
|
|
61
|
+
|
|
62
|
+
Examples
|
|
63
|
+
--------
|
|
64
|
+
>>> from pathlib import Path
|
|
65
|
+
>>> config = ExperimentConfig(
|
|
66
|
+
... experiment_type="likert_rating",
|
|
67
|
+
... title="Acceptability Study",
|
|
68
|
+
... description="Rate sentences",
|
|
69
|
+
... instructions="Rate each sentence from 1 to 7"
|
|
70
|
+
... )
|
|
71
|
+
>>> generator = JsPsychExperimentGenerator(
|
|
72
|
+
... config=config,
|
|
73
|
+
... output_dir=Path("/tmp/experiment")
|
|
74
|
+
... )
|
|
75
|
+
>>> # generator.generate(lists, items)
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
config: ExperimentConfig,
|
|
81
|
+
output_dir: Path,
|
|
82
|
+
rating_config: RatingScaleConfig | None = None,
|
|
83
|
+
choice_config: ChoiceConfig | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.config = config
|
|
86
|
+
self.output_dir = Path(output_dir)
|
|
87
|
+
self.rating_config = rating_config or RatingScaleConfig()
|
|
88
|
+
self.choice_config = choice_config or ChoiceConfig()
|
|
89
|
+
|
|
90
|
+
# Setup Jinja2 environment
|
|
91
|
+
template_dir = Path(__file__).parent / "templates"
|
|
92
|
+
self.jinja_env = Environment(loader=FileSystemLoader(str(template_dir)))
|
|
93
|
+
|
|
94
|
+
def generate(
|
|
95
|
+
self,
|
|
96
|
+
lists: list[ExperimentList],
|
|
97
|
+
items: dict[UUID, Item],
|
|
98
|
+
templates: dict[UUID, ItemTemplate],
|
|
99
|
+
) -> Path:
|
|
100
|
+
"""Generate complete jsPsych batch experiment.
|
|
101
|
+
|
|
102
|
+
Creates a unified batch experiment that uses JATOS batch sessions for
|
|
103
|
+
server-side list distribution. All participants are automatically assigned
|
|
104
|
+
to lists according to the distribution strategy specified in the experiment
|
|
105
|
+
configuration.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
lists : list[ExperimentList]
|
|
110
|
+
Experiment lists for batch distribution (required, must be non-empty).
|
|
111
|
+
All lists will be serialized to lists.jsonl and made available for
|
|
112
|
+
participant assignment.
|
|
113
|
+
items : dict[UUID, Item]
|
|
114
|
+
Dictionary of items keyed by UUID (required, must be non-empty).
|
|
115
|
+
All items referenced by lists must be present in this dictionary.
|
|
116
|
+
templates : dict[UUID, ItemTemplate]
|
|
117
|
+
Dictionary of item templates keyed by UUID (required, must be non-empty).
|
|
118
|
+
All templates referenced by items must be present in this dictionary.
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
Path
|
|
123
|
+
Path to the generated experiment directory containing:
|
|
124
|
+
- index.html
|
|
125
|
+
- js/experiment.js, js/list_distributor.js
|
|
126
|
+
- css/experiment.css
|
|
127
|
+
- data/config.json, data/lists.jsonl, data/items.jsonl,
|
|
128
|
+
data/distribution.json
|
|
129
|
+
|
|
130
|
+
Raises
|
|
131
|
+
------
|
|
132
|
+
ValueError
|
|
133
|
+
If lists is empty, items is empty, templates is empty, or if any
|
|
134
|
+
referenced UUIDs are not found in the provided dictionaries.
|
|
135
|
+
SerializationError
|
|
136
|
+
If writing JSONL files fails.
|
|
137
|
+
|
|
138
|
+
Examples
|
|
139
|
+
--------
|
|
140
|
+
>>> from pathlib import Path
|
|
141
|
+
>>> from bead.deployment.distribution import (
|
|
142
|
+
... ListDistributionStrategy, DistributionStrategyType
|
|
143
|
+
... )
|
|
144
|
+
>>> strategy = ListDistributionStrategy(
|
|
145
|
+
... strategy_type=DistributionStrategyType.BALANCED
|
|
146
|
+
... )
|
|
147
|
+
>>> config = ExperimentConfig(
|
|
148
|
+
... experiment_type="forced_choice",
|
|
149
|
+
... title="Test",
|
|
150
|
+
... description="Test",
|
|
151
|
+
... instructions="Test",
|
|
152
|
+
... distribution_strategy=strategy
|
|
153
|
+
... )
|
|
154
|
+
>>> generator = JsPsychExperimentGenerator(
|
|
155
|
+
... config=config, output_dir=Path("/tmp/exp")
|
|
156
|
+
... )
|
|
157
|
+
>>> # output_dir = generator.generate(lists, items, templates)
|
|
158
|
+
"""
|
|
159
|
+
# Validate inputs (no fallbacks)
|
|
160
|
+
if not lists:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
"generate() requires at least one ExperimentList. Got empty list."
|
|
163
|
+
" Create lists using ListPartitioner before calling generate()."
|
|
164
|
+
" Example: partitioner.partition_with_batch_constraints(...)"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if not items:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
"generate() requires items dictionary. Got empty dict."
|
|
170
|
+
" Ensure items are constructed before calling generate()."
|
|
171
|
+
" Items must be created using bead.items utilities."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if not templates:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
"generate() requires templates dictionary. Got empty dict. "
|
|
177
|
+
"Ensure item templates are included. If items don't use templates, "
|
|
178
|
+
"provide an empty template: {item.item_template_id: ItemTemplate(...)}."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Validate all item references can be resolved
|
|
182
|
+
self._validate_item_references(lists, items)
|
|
183
|
+
|
|
184
|
+
# Validate all template references can be resolved
|
|
185
|
+
self._validate_template_references(items, templates)
|
|
186
|
+
|
|
187
|
+
# Create directory structure
|
|
188
|
+
self._create_directory_structure()
|
|
189
|
+
|
|
190
|
+
# Write batch data files (lists, items, distribution config, trials)
|
|
191
|
+
self._write_lists_jsonl(lists)
|
|
192
|
+
self._write_items_jsonl(items)
|
|
193
|
+
self._write_distribution_config()
|
|
194
|
+
self._write_trials_json(lists, items, templates)
|
|
195
|
+
|
|
196
|
+
# Generate HTML/CSS/JS files
|
|
197
|
+
self._generate_html()
|
|
198
|
+
self._generate_css()
|
|
199
|
+
self._generate_experiment_script()
|
|
200
|
+
self._generate_config_file()
|
|
201
|
+
self._copy_list_distributor_script()
|
|
202
|
+
|
|
203
|
+
# Copy slopit bundle if enabled
|
|
204
|
+
if self.config.slopit.enabled:
|
|
205
|
+
self._copy_slopit_bundle()
|
|
206
|
+
|
|
207
|
+
return self.output_dir
|
|
208
|
+
|
|
209
|
+
def _validate_item_references(
|
|
210
|
+
self,
|
|
211
|
+
lists: list[ExperimentList],
|
|
212
|
+
items: dict[UUID, Item],
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Validate all item UUIDs in lists can be resolved.
|
|
215
|
+
|
|
216
|
+
Parameters
|
|
217
|
+
----------
|
|
218
|
+
lists : list[ExperimentList]
|
|
219
|
+
Lists to validate.
|
|
220
|
+
items : dict[UUID, Item]
|
|
221
|
+
Items dictionary.
|
|
222
|
+
|
|
223
|
+
Raises
|
|
224
|
+
------
|
|
225
|
+
ValueError
|
|
226
|
+
If any item UUID in lists is not found in items dict.
|
|
227
|
+
"""
|
|
228
|
+
for exp_list in lists:
|
|
229
|
+
for item_id in exp_list.item_refs:
|
|
230
|
+
if item_id not in items:
|
|
231
|
+
available_sample = list(items.keys())[:5]
|
|
232
|
+
ellipsis = "..." if len(items) > 5 else ""
|
|
233
|
+
raise ValueError(
|
|
234
|
+
f"Item {item_id} referenced in list '{exp_list.name}' "
|
|
235
|
+
f"(list_number={exp_list.list_number}) not found in items. "
|
|
236
|
+
f"Available UUIDs (first 5): {available_sample}{ellipsis}. "
|
|
237
|
+
f"Include all referenced items in items dict."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _validate_template_references(
|
|
241
|
+
self,
|
|
242
|
+
items: dict[UUID, Item],
|
|
243
|
+
templates: dict[UUID, ItemTemplate],
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Validate all template UUIDs in items can be resolved.
|
|
246
|
+
|
|
247
|
+
Parameters
|
|
248
|
+
----------
|
|
249
|
+
items : dict[UUID, Item]
|
|
250
|
+
Items dictionary.
|
|
251
|
+
templates : dict[UUID, ItemTemplate]
|
|
252
|
+
Templates dictionary.
|
|
253
|
+
|
|
254
|
+
Raises
|
|
255
|
+
------
|
|
256
|
+
ValueError
|
|
257
|
+
If any template UUID in items is not found in templates dict.
|
|
258
|
+
"""
|
|
259
|
+
for item_id, item in items.items():
|
|
260
|
+
if item.item_template_id not in templates:
|
|
261
|
+
available_sample = list(templates.keys())[:5]
|
|
262
|
+
ellipsis = "..." if len(templates) > 5 else ""
|
|
263
|
+
raise ValueError(
|
|
264
|
+
f"Template {item.item_template_id} for item {item_id} "
|
|
265
|
+
f"not found in templates. "
|
|
266
|
+
f"Available UUIDs (first 5): {available_sample}{ellipsis}. "
|
|
267
|
+
f"Include all referenced templates in templates dict."
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _write_lists_jsonl(self, lists: list[ExperimentList]) -> None:
|
|
271
|
+
"""Write experiment lists to data/lists.jsonl.
|
|
272
|
+
|
|
273
|
+
Parameters
|
|
274
|
+
----------
|
|
275
|
+
lists : list[ExperimentList]
|
|
276
|
+
Lists to serialize.
|
|
277
|
+
|
|
278
|
+
Raises
|
|
279
|
+
------
|
|
280
|
+
SerializationError
|
|
281
|
+
If writing JSONL fails.
|
|
282
|
+
"""
|
|
283
|
+
output_path = self.output_dir / "data" / "lists.jsonl"
|
|
284
|
+
try:
|
|
285
|
+
write_jsonlines(lists, output_path)
|
|
286
|
+
except SerializationError as e:
|
|
287
|
+
raise SerializationError(
|
|
288
|
+
f"Failed to write lists.jsonl to {output_path}: {e}. "
|
|
289
|
+
f"Check write permissions and disk space. "
|
|
290
|
+
f"Attempted to serialize {len(lists)} lists."
|
|
291
|
+
) from e
|
|
292
|
+
|
|
293
|
+
def _write_items_jsonl(self, items: dict[UUID, Item]) -> None:
|
|
294
|
+
"""Write items to data/items.jsonl.
|
|
295
|
+
|
|
296
|
+
Parameters
|
|
297
|
+
----------
|
|
298
|
+
items : dict[UUID, Item]
|
|
299
|
+
Items dictionary to serialize.
|
|
300
|
+
|
|
301
|
+
Raises
|
|
302
|
+
------
|
|
303
|
+
SerializationError
|
|
304
|
+
If writing JSONL fails.
|
|
305
|
+
"""
|
|
306
|
+
output_path = self.output_dir / "data" / "items.jsonl"
|
|
307
|
+
try:
|
|
308
|
+
# Convert dict values to list for serialization
|
|
309
|
+
items_list = list(items.values())
|
|
310
|
+
write_jsonlines(items_list, output_path)
|
|
311
|
+
except SerializationError as e:
|
|
312
|
+
raise SerializationError(
|
|
313
|
+
f"Failed to write items.jsonl to {output_path}: {e}. "
|
|
314
|
+
f"Check write permissions and disk space. "
|
|
315
|
+
f"Attempted to serialize {len(items)} items."
|
|
316
|
+
) from e
|
|
317
|
+
|
|
318
|
+
def _write_trials_json(
|
|
319
|
+
self,
|
|
320
|
+
lists: list[ExperimentList],
|
|
321
|
+
items: dict[UUID, Item],
|
|
322
|
+
templates: dict[UUID, ItemTemplate],
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Write pre-generated trials to data/trials.json.
|
|
325
|
+
|
|
326
|
+
Creates trials for each list and stores them in a JSON file
|
|
327
|
+
keyed by list ID for efficient loading in the experiment.
|
|
328
|
+
|
|
329
|
+
Parameters
|
|
330
|
+
----------
|
|
331
|
+
lists : list[ExperimentList]
|
|
332
|
+
Experiment lists.
|
|
333
|
+
items : dict[UUID, Item]
|
|
334
|
+
Items dictionary.
|
|
335
|
+
templates : dict[UUID, ItemTemplate]
|
|
336
|
+
Templates dictionary.
|
|
337
|
+
|
|
338
|
+
Raises
|
|
339
|
+
------
|
|
340
|
+
SerializationError
|
|
341
|
+
If writing JSON fails.
|
|
342
|
+
"""
|
|
343
|
+
output_path = self.output_dir / "data" / "trials.json"
|
|
344
|
+
trials_by_list: dict[str, list[dict[str, JsonValue]]] = {}
|
|
345
|
+
|
|
346
|
+
for exp_list in lists:
|
|
347
|
+
list_trials: list[dict[str, JsonValue]] = []
|
|
348
|
+
for trial_num, item_id in enumerate(exp_list.item_refs):
|
|
349
|
+
item = items[item_id]
|
|
350
|
+
template = templates[item.item_template_id]
|
|
351
|
+
trial = create_trial(
|
|
352
|
+
item=item,
|
|
353
|
+
template=template,
|
|
354
|
+
experiment_config=self.config,
|
|
355
|
+
trial_number=trial_num,
|
|
356
|
+
rating_config=self.rating_config,
|
|
357
|
+
choice_config=self.choice_config,
|
|
358
|
+
)
|
|
359
|
+
list_trials.append(trial)
|
|
360
|
+
trials_by_list[str(exp_list.id)] = list_trials
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
364
|
+
json.dump(trials_by_list, f, indent=2)
|
|
365
|
+
except Exception as e:
|
|
366
|
+
raise SerializationError(
|
|
367
|
+
f"Failed to write trials.json to {output_path}: {e}"
|
|
368
|
+
) from e
|
|
369
|
+
|
|
370
|
+
def _write_distribution_config(self) -> None:
|
|
371
|
+
"""Write distribution strategy config to data/distribution.json.
|
|
372
|
+
|
|
373
|
+
Raises
|
|
374
|
+
------
|
|
375
|
+
SerializationError
|
|
376
|
+
If writing JSON fails.
|
|
377
|
+
"""
|
|
378
|
+
output_path = self.output_dir / "data" / "distribution.json"
|
|
379
|
+
try:
|
|
380
|
+
# Use model_dump_json() to handle UUID serialization
|
|
381
|
+
json_str = self.config.distribution_strategy.model_dump_json(indent=2)
|
|
382
|
+
output_path.write_text(json_str)
|
|
383
|
+
except (OSError, TypeError) as e:
|
|
384
|
+
raise SerializationError(
|
|
385
|
+
f"Failed to write distribution.json to {output_path}: {e}. "
|
|
386
|
+
f"Check write permissions and disk space. "
|
|
387
|
+
f"Strategy type: {self.config.distribution_strategy.strategy_type}"
|
|
388
|
+
) from e
|
|
389
|
+
|
|
390
|
+
def _copy_list_distributor_script(self) -> None:
|
|
391
|
+
"""Copy list_distributor.js from compiled dist/ to js/ directory.
|
|
392
|
+
|
|
393
|
+
Raises
|
|
394
|
+
------
|
|
395
|
+
FileNotFoundError
|
|
396
|
+
If list_distributor.js is not found in dist/.
|
|
397
|
+
OSError
|
|
398
|
+
If copying fails.
|
|
399
|
+
"""
|
|
400
|
+
dist_path = Path(__file__).parent / "dist" / "lib" / "list-distributor.js"
|
|
401
|
+
output_path = self.output_dir / "js" / "list_distributor.js"
|
|
402
|
+
|
|
403
|
+
if not dist_path.exists():
|
|
404
|
+
raise FileNotFoundError(
|
|
405
|
+
f"list-distributor.js not found at {dist_path}. "
|
|
406
|
+
f"Ensure TypeScript is compiled. "
|
|
407
|
+
f"Run 'npm run build' in the jspsych directory."
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
output_path.write_text(dist_path.read_text())
|
|
412
|
+
except OSError as e:
|
|
413
|
+
raise OSError(
|
|
414
|
+
f"Failed to copy list_distributor.js to {output_path}: {e}. "
|
|
415
|
+
f"Check write permissions."
|
|
416
|
+
) from e
|
|
417
|
+
|
|
418
|
+
def _create_directory_structure(self) -> None:
|
|
419
|
+
"""Create output directory structure.
|
|
420
|
+
|
|
421
|
+
Creates:
|
|
422
|
+
- output_dir/
|
|
423
|
+
- output_dir/css/
|
|
424
|
+
- output_dir/js/
|
|
425
|
+
- output_dir/data/
|
|
426
|
+
"""
|
|
427
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
428
|
+
(self.output_dir / "css").mkdir(exist_ok=True)
|
|
429
|
+
(self.output_dir / "js").mkdir(exist_ok=True)
|
|
430
|
+
(self.output_dir / "data").mkdir(exist_ok=True)
|
|
431
|
+
|
|
432
|
+
def _generate_html(self) -> None:
|
|
433
|
+
"""Generate index.html file."""
|
|
434
|
+
template = self.jinja_env.get_template("index.html")
|
|
435
|
+
|
|
436
|
+
html_content = template.render(
|
|
437
|
+
title=self.config.title,
|
|
438
|
+
ui_theme=self.config.ui_theme,
|
|
439
|
+
use_jatos=self.config.use_jatos,
|
|
440
|
+
slopit_enabled=self.config.slopit.enabled,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
output_file = self.output_dir / "index.html"
|
|
444
|
+
output_file.write_text(html_content)
|
|
445
|
+
|
|
446
|
+
def _generate_css(self) -> None:
|
|
447
|
+
"""Generate experiment.css file by copying template."""
|
|
448
|
+
template_file = Path(__file__).parent / "templates" / "experiment.css"
|
|
449
|
+
output_file = self.output_dir / "css" / "experiment.css"
|
|
450
|
+
|
|
451
|
+
# Copy CSS template directly (no rendering needed)
|
|
452
|
+
output_file.write_text(template_file.read_text())
|
|
453
|
+
|
|
454
|
+
def _generate_experiment_script(self) -> None:
|
|
455
|
+
"""Generate experiment.js file."""
|
|
456
|
+
template = self.jinja_env.get_template("experiment.js.template")
|
|
457
|
+
|
|
458
|
+
# Auto-generate Prolific redirect URL if completion code is provided
|
|
459
|
+
on_finish_url = self.config.on_finish_url
|
|
460
|
+
if self.config.prolific_completion_code:
|
|
461
|
+
on_finish_url = (
|
|
462
|
+
f"https://app.prolific.co/submissions/complete?"
|
|
463
|
+
f"cc={self.config.prolific_completion_code}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# Prepare slopit config for template
|
|
467
|
+
slopit_config = None
|
|
468
|
+
if self.config.slopit.enabled:
|
|
469
|
+
slopit_config = {
|
|
470
|
+
"keystroke": self.config.slopit.keystroke.model_dump(),
|
|
471
|
+
"focus": self.config.slopit.focus.model_dump(),
|
|
472
|
+
"paste": self.config.slopit.paste.model_dump(),
|
|
473
|
+
"target_selectors": self.config.slopit.target_selectors,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
# Prepare demographics config for template
|
|
477
|
+
demographics_enabled = False
|
|
478
|
+
demographics_title = "Participant Information"
|
|
479
|
+
demographics_fields: list[dict[str, JsonValue]] = []
|
|
480
|
+
demographics_submit_text = "Continue"
|
|
481
|
+
|
|
482
|
+
if self.config.demographics is not None and self.config.demographics.enabled:
|
|
483
|
+
demographics_enabled = True
|
|
484
|
+
demographics_title = self.config.demographics.title
|
|
485
|
+
demographics_submit_text = self.config.demographics.submit_button_text
|
|
486
|
+
for field in self.config.demographics.fields:
|
|
487
|
+
field_data: dict[str, JsonValue] = {
|
|
488
|
+
"name": field.name,
|
|
489
|
+
"label": field.label,
|
|
490
|
+
"field_type": field.field_type,
|
|
491
|
+
"required": field.required,
|
|
492
|
+
}
|
|
493
|
+
if field.placeholder:
|
|
494
|
+
field_data["placeholder"] = field.placeholder
|
|
495
|
+
if field.options:
|
|
496
|
+
field_data["options"] = field.options
|
|
497
|
+
if field.range is not None:
|
|
498
|
+
field_data["range_min"] = field.range.min
|
|
499
|
+
field_data["range_max"] = field.range.max
|
|
500
|
+
demographics_fields.append(field_data)
|
|
501
|
+
|
|
502
|
+
# Prepare instructions config for template
|
|
503
|
+
instructions_is_multi_page = isinstance(
|
|
504
|
+
self.config.instructions, InstructionsConfig
|
|
505
|
+
)
|
|
506
|
+
instructions_pages: list[dict[str, str | None]] = []
|
|
507
|
+
instructions_show_page_numbers = True
|
|
508
|
+
instructions_allow_backwards = True
|
|
509
|
+
instructions_button_next = "Next"
|
|
510
|
+
instructions_button_finish = "Begin Experiment"
|
|
511
|
+
simple_instructions: str | None = None
|
|
512
|
+
|
|
513
|
+
if instructions_is_multi_page:
|
|
514
|
+
assert isinstance(self.config.instructions, InstructionsConfig)
|
|
515
|
+
instructions_show_page_numbers = self.config.instructions.show_page_numbers
|
|
516
|
+
instructions_allow_backwards = self.config.instructions.allow_backwards
|
|
517
|
+
instructions_button_next = self.config.instructions.button_label_next
|
|
518
|
+
instructions_button_finish = self.config.instructions.button_label_finish
|
|
519
|
+
for page in self.config.instructions.pages:
|
|
520
|
+
instructions_pages.append(
|
|
521
|
+
{
|
|
522
|
+
"title": page.title,
|
|
523
|
+
"content": page.content,
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
else:
|
|
527
|
+
# Simple string instructions
|
|
528
|
+
simple_instructions = (
|
|
529
|
+
self.config.instructions
|
|
530
|
+
if isinstance(self.config.instructions, str)
|
|
531
|
+
else None
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
js_content = template.render(
|
|
535
|
+
title=self.config.title,
|
|
536
|
+
description=self.config.description,
|
|
537
|
+
instructions=simple_instructions,
|
|
538
|
+
show_progress_bar=self.config.show_progress_bar,
|
|
539
|
+
use_jatos=self.config.use_jatos,
|
|
540
|
+
on_finish_url=on_finish_url,
|
|
541
|
+
slopit_enabled=self.config.slopit.enabled,
|
|
542
|
+
slopit_config=slopit_config,
|
|
543
|
+
# Demographics variables
|
|
544
|
+
demographics_enabled=demographics_enabled,
|
|
545
|
+
demographics_title=demographics_title,
|
|
546
|
+
demographics_fields=demographics_fields,
|
|
547
|
+
demographics_submit_text=demographics_submit_text,
|
|
548
|
+
# Instructions variables
|
|
549
|
+
instructions_is_multi_page=instructions_is_multi_page,
|
|
550
|
+
instructions_pages=instructions_pages,
|
|
551
|
+
instructions_show_page_numbers=instructions_show_page_numbers,
|
|
552
|
+
instructions_allow_backwards=instructions_allow_backwards,
|
|
553
|
+
instructions_button_next=instructions_button_next,
|
|
554
|
+
instructions_button_finish=instructions_button_finish,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
output_file = self.output_dir / "js" / "experiment.js"
|
|
558
|
+
output_file.write_text(js_content)
|
|
559
|
+
|
|
560
|
+
def _generate_config_file(self) -> None:
|
|
561
|
+
"""Generate config.json file with experiment configuration."""
|
|
562
|
+
output_file = self.output_dir / "data" / "config.json"
|
|
563
|
+
json_str = self.config.model_dump_json(indent=2)
|
|
564
|
+
output_file.write_text(json_str)
|
|
565
|
+
|
|
566
|
+
def _copy_slopit_bundle(self) -> None:
|
|
567
|
+
"""Copy slopit bundle to js/ directory.
|
|
568
|
+
|
|
569
|
+
Copies the pre-built slopit bundle from the bead deployment dist
|
|
570
|
+
directory to the experiment output directory.
|
|
571
|
+
|
|
572
|
+
Raises
|
|
573
|
+
------
|
|
574
|
+
FileNotFoundError
|
|
575
|
+
If slopit bundle is not found.
|
|
576
|
+
OSError
|
|
577
|
+
If copying fails.
|
|
578
|
+
"""
|
|
579
|
+
# Look for slopit bundle in dist directory
|
|
580
|
+
dist_dir = Path(__file__).parent / "dist"
|
|
581
|
+
bundle_path = dist_dir / "slopit-bundle.js"
|
|
582
|
+
|
|
583
|
+
if not bundle_path.exists():
|
|
584
|
+
raise FileNotFoundError(
|
|
585
|
+
f"Slopit bundle not found at {bundle_path}. "
|
|
586
|
+
f"Ensure the slopit packages are built. "
|
|
587
|
+
f"Run 'npm run build' in the jspsych directory, or install "
|
|
588
|
+
f"bead with: pip install bead[behavioral-analysis]"
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
output_path = self.output_dir / "js" / "slopit-bundle.js"
|
|
592
|
+
try:
|
|
593
|
+
output_path.write_text(bundle_path.read_text())
|
|
594
|
+
except OSError as e:
|
|
595
|
+
raise OSError(
|
|
596
|
+
f"Failed to copy slopit bundle to {output_path}: {e}. "
|
|
597
|
+
f"Check write permissions."
|
|
598
|
+
) from e
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bead/jspsych-deployment",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript plugins and utilities for bead jsPsych experiment deployment",
|
|
5
|
+
"private": true,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./plugins/*": {
|
|
9
|
+
"import": "./dist/plugins/*.js",
|
|
10
|
+
"types": "./dist/plugins/*.d.ts"
|
|
11
|
+
},
|
|
12
|
+
"./lib/*": {
|
|
13
|
+
"import": "./dist/lib/*.js",
|
|
14
|
+
"types": "./dist/lib/*.d.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup",
|
|
19
|
+
"build:watch": "tsup --watch",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"lint": "biome lint src",
|
|
22
|
+
"lint:fix": "biome lint --write src",
|
|
23
|
+
"format": "biome format --check src",
|
|
24
|
+
"format:fix": "biome format --write src",
|
|
25
|
+
"check": "biome check src",
|
|
26
|
+
"check:fix": "biome check --write src",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest",
|
|
29
|
+
"clean": "rm -rf dist"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@slopit/adapter-jspsych": "^0.1.0",
|
|
33
|
+
"@slopit/behavioral": "^0.1.0",
|
|
34
|
+
"@slopit/core": "^0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@biomejs/biome": "^1.9.0",
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"jsdom": "^25.0.0",
|
|
40
|
+
"jspsych": "^8.0.0",
|
|
41
|
+
"tsup": "^8.0.0",
|
|
42
|
+
"typescript": "^5.7.0",
|
|
43
|
+
"vitest": "^2.0.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"jspsych": "^8.0.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|