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,491 @@
|
|
|
1
|
+
"""Metadata specification for participant attributes.
|
|
2
|
+
|
|
3
|
+
This module provides FieldSpec and ParticipantMetadataSpec for defining
|
|
4
|
+
configurable metadata fields with validation constraints (allowed values, ranges).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
12
|
+
|
|
13
|
+
from bead.data.range import Range
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from bead.deployment.jspsych.config import DemographicsConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _empty_field_spec_list() -> list[FieldSpec]:
|
|
20
|
+
"""Return empty field spec list."""
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FieldSpec(BaseModel):
|
|
25
|
+
"""Specification for a single metadata field.
|
|
26
|
+
|
|
27
|
+
Defines the constraints and display properties for a participant metadata
|
|
28
|
+
field. Used for validation and for generating demographics forms.
|
|
29
|
+
|
|
30
|
+
Attributes
|
|
31
|
+
----------
|
|
32
|
+
name : str
|
|
33
|
+
Field name (e.g., "age", "education"). Must be valid Python identifier.
|
|
34
|
+
field_type : Literal["int", "float", "str", "bool"]
|
|
35
|
+
Data type for the field.
|
|
36
|
+
required : bool
|
|
37
|
+
Whether this field is required (default: False).
|
|
38
|
+
allowed_values : list[str | int | float | bool] | None
|
|
39
|
+
Exhaustive list of allowed values (for categorical fields).
|
|
40
|
+
If None, any value of the correct type is accepted.
|
|
41
|
+
range : Range[int] | Range[float] | None
|
|
42
|
+
Numeric range constraint (for int/float fields).
|
|
43
|
+
label : str | None
|
|
44
|
+
Display label for forms. If None, uses name with underscores replaced.
|
|
45
|
+
description : str | None
|
|
46
|
+
Help text / description for the field.
|
|
47
|
+
|
|
48
|
+
Examples
|
|
49
|
+
--------
|
|
50
|
+
>>> age_spec = FieldSpec(
|
|
51
|
+
... name="age",
|
|
52
|
+
... field_type="int",
|
|
53
|
+
... required=True,
|
|
54
|
+
... range=Range[int](min=18, max=100),
|
|
55
|
+
... label="Age",
|
|
56
|
+
... description="Your age in years"
|
|
57
|
+
... )
|
|
58
|
+
>>> education_spec = FieldSpec(
|
|
59
|
+
... name="education",
|
|
60
|
+
... field_type="str",
|
|
61
|
+
... required=True,
|
|
62
|
+
... allowed_values=["high_school", "bachelors", "masters", "phd"],
|
|
63
|
+
... label="Highest Education Level"
|
|
64
|
+
... )
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
68
|
+
|
|
69
|
+
name: str
|
|
70
|
+
field_type: Literal["int", "float", "str", "bool"]
|
|
71
|
+
required: bool = False
|
|
72
|
+
allowed_values: list[str | int | float | bool] | None = None
|
|
73
|
+
range: Range[int] | Range[float] | None = None
|
|
74
|
+
label: str | None = None
|
|
75
|
+
description: str | None = None
|
|
76
|
+
|
|
77
|
+
@field_validator("name")
|
|
78
|
+
@classmethod
|
|
79
|
+
def validate_name(cls, v: str) -> str:
|
|
80
|
+
"""Validate field name is non-empty and valid identifier.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
v : str
|
|
85
|
+
Field name to validate.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
str
|
|
90
|
+
Validated field name.
|
|
91
|
+
|
|
92
|
+
Raises
|
|
93
|
+
------
|
|
94
|
+
ValueError
|
|
95
|
+
If field name is empty or not a valid Python identifier.
|
|
96
|
+
"""
|
|
97
|
+
if not v or not v.strip():
|
|
98
|
+
raise ValueError("Field name cannot be empty")
|
|
99
|
+
v = v.strip()
|
|
100
|
+
if not v.isidentifier():
|
|
101
|
+
raise ValueError(f"Field name must be valid Python identifier: {v}")
|
|
102
|
+
return v
|
|
103
|
+
|
|
104
|
+
@model_validator(mode="after")
|
|
105
|
+
def validate_constraints(self) -> FieldSpec:
|
|
106
|
+
"""Validate that constraints are consistent with field_type.
|
|
107
|
+
|
|
108
|
+
Returns
|
|
109
|
+
-------
|
|
110
|
+
FieldSpec
|
|
111
|
+
The validated FieldSpec instance.
|
|
112
|
+
|
|
113
|
+
Raises
|
|
114
|
+
------
|
|
115
|
+
ValueError
|
|
116
|
+
If constraints are inconsistent with field_type.
|
|
117
|
+
"""
|
|
118
|
+
# Range constraints only valid for numeric types
|
|
119
|
+
if self.range is not None:
|
|
120
|
+
if self.field_type not in ("int", "float"):
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"range constraint only valid for numeric types, "
|
|
123
|
+
f"not {self.field_type}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Validate allowed_values types match field_type
|
|
127
|
+
if self.allowed_values is not None:
|
|
128
|
+
expected_type: type | tuple[type, ...]
|
|
129
|
+
if self.field_type == "int":
|
|
130
|
+
expected_type = int
|
|
131
|
+
elif self.field_type == "float":
|
|
132
|
+
expected_type = (int, float)
|
|
133
|
+
elif self.field_type == "str":
|
|
134
|
+
expected_type = str
|
|
135
|
+
else: # bool
|
|
136
|
+
expected_type = bool
|
|
137
|
+
|
|
138
|
+
for val in self.allowed_values:
|
|
139
|
+
if not isinstance(val, expected_type):
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"allowed_values item {val!r} does not match "
|
|
142
|
+
f"field_type {self.field_type}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def validate_value(self, value: str | int | float | bool | None) -> bool:
|
|
148
|
+
"""Check if a value satisfies this field's constraints.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
value : str | int | float | bool | None
|
|
153
|
+
Value to validate.
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
bool
|
|
158
|
+
True if value is valid, False otherwise.
|
|
159
|
+
|
|
160
|
+
Examples
|
|
161
|
+
--------
|
|
162
|
+
>>> spec = FieldSpec(
|
|
163
|
+
... name="age",
|
|
164
|
+
... field_type="int",
|
|
165
|
+
... range=Range[int](min=18, max=100)
|
|
166
|
+
... )
|
|
167
|
+
>>> spec.validate_value(25)
|
|
168
|
+
True
|
|
169
|
+
>>> spec.validate_value(10)
|
|
170
|
+
False
|
|
171
|
+
"""
|
|
172
|
+
if value is None:
|
|
173
|
+
return not self.required
|
|
174
|
+
|
|
175
|
+
# Type check
|
|
176
|
+
expected_type: type | tuple[type, ...]
|
|
177
|
+
if self.field_type == "int":
|
|
178
|
+
expected_type = int
|
|
179
|
+
elif self.field_type == "float":
|
|
180
|
+
expected_type = (int, float)
|
|
181
|
+
elif self.field_type == "str":
|
|
182
|
+
expected_type = str
|
|
183
|
+
else: # bool
|
|
184
|
+
expected_type = bool
|
|
185
|
+
|
|
186
|
+
if not isinstance(value, expected_type):
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
# Allowed values check
|
|
190
|
+
if self.allowed_values is not None and value not in self.allowed_values:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
# Range check
|
|
194
|
+
if self.range is not None and isinstance(value, int | float):
|
|
195
|
+
if not self.range.contains(value): # type: ignore[arg-type]
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
def get_display_label(self) -> str:
|
|
201
|
+
"""Get display label for forms.
|
|
202
|
+
|
|
203
|
+
Returns
|
|
204
|
+
-------
|
|
205
|
+
str
|
|
206
|
+
The label if set, otherwise name with underscores replaced by spaces
|
|
207
|
+
and title-cased.
|
|
208
|
+
|
|
209
|
+
Examples
|
|
210
|
+
--------
|
|
211
|
+
>>> spec = FieldSpec(name="native_speaker", field_type="bool")
|
|
212
|
+
>>> spec.get_display_label()
|
|
213
|
+
'Native Speaker'
|
|
214
|
+
>>> spec = FieldSpec(name="age", field_type="int", label="Your Age")
|
|
215
|
+
>>> spec.get_display_label()
|
|
216
|
+
'Your Age'
|
|
217
|
+
"""
|
|
218
|
+
if self.label:
|
|
219
|
+
return self.label
|
|
220
|
+
return self.name.replace("_", " ").title()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class ParticipantMetadataSpec(BaseModel):
|
|
224
|
+
"""Specification for participant metadata schema.
|
|
225
|
+
|
|
226
|
+
Defines the allowed fields and their constraints for participant
|
|
227
|
+
metadata. Used to validate participant data on ingestion and to
|
|
228
|
+
generate demographics forms for experiments.
|
|
229
|
+
|
|
230
|
+
Attributes
|
|
231
|
+
----------
|
|
232
|
+
name : str
|
|
233
|
+
Name of this specification (e.g., "prolific_demographics").
|
|
234
|
+
version : str
|
|
235
|
+
Version string for this spec.
|
|
236
|
+
fields : list[FieldSpec]
|
|
237
|
+
List of field specifications.
|
|
238
|
+
|
|
239
|
+
Examples
|
|
240
|
+
--------
|
|
241
|
+
>>> spec = ParticipantMetadataSpec(
|
|
242
|
+
... name="standard_demographics",
|
|
243
|
+
... version="1.0.0",
|
|
244
|
+
... fields=[
|
|
245
|
+
... FieldSpec(
|
|
246
|
+
... name="age",
|
|
247
|
+
... field_type="int",
|
|
248
|
+
... range=Range[int](min=18, max=100)
|
|
249
|
+
... ),
|
|
250
|
+
... FieldSpec(
|
|
251
|
+
... name="education",
|
|
252
|
+
... field_type="str",
|
|
253
|
+
... allowed_values=["high_school", "bachelors", "masters", "phd"]
|
|
254
|
+
... ),
|
|
255
|
+
... FieldSpec(name="native_speaker", field_type="bool", required=True),
|
|
256
|
+
... ]
|
|
257
|
+
... )
|
|
258
|
+
>>> spec.get_field("age").range.min
|
|
259
|
+
18
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
model_config = ConfigDict(extra="forbid")
|
|
263
|
+
|
|
264
|
+
name: str
|
|
265
|
+
version: str = "1.0.0"
|
|
266
|
+
fields: list[FieldSpec] = Field(default_factory=_empty_field_spec_list)
|
|
267
|
+
|
|
268
|
+
@field_validator("name")
|
|
269
|
+
@classmethod
|
|
270
|
+
def validate_name(cls, v: str) -> str:
|
|
271
|
+
"""Validate spec name is non-empty.
|
|
272
|
+
|
|
273
|
+
Parameters
|
|
274
|
+
----------
|
|
275
|
+
v : str
|
|
276
|
+
Spec name to validate.
|
|
277
|
+
|
|
278
|
+
Returns
|
|
279
|
+
-------
|
|
280
|
+
str
|
|
281
|
+
Validated spec name.
|
|
282
|
+
|
|
283
|
+
Raises
|
|
284
|
+
------
|
|
285
|
+
ValueError
|
|
286
|
+
If name is empty.
|
|
287
|
+
"""
|
|
288
|
+
if not v or not v.strip():
|
|
289
|
+
raise ValueError("Spec name cannot be empty")
|
|
290
|
+
return v.strip()
|
|
291
|
+
|
|
292
|
+
@field_validator("fields")
|
|
293
|
+
@classmethod
|
|
294
|
+
def validate_unique_field_names(cls, v: list[FieldSpec]) -> list[FieldSpec]:
|
|
295
|
+
"""Validate all field names are unique.
|
|
296
|
+
|
|
297
|
+
Parameters
|
|
298
|
+
----------
|
|
299
|
+
v : list[FieldSpec]
|
|
300
|
+
List of field specs to validate.
|
|
301
|
+
|
|
302
|
+
Returns
|
|
303
|
+
-------
|
|
304
|
+
list[FieldSpec]
|
|
305
|
+
Validated list of field specs.
|
|
306
|
+
|
|
307
|
+
Raises
|
|
308
|
+
------
|
|
309
|
+
ValueError
|
|
310
|
+
If duplicate field names found.
|
|
311
|
+
"""
|
|
312
|
+
names = [f.name for f in v]
|
|
313
|
+
if len(names) != len(set(names)):
|
|
314
|
+
duplicates = [n for n in names if names.count(n) > 1]
|
|
315
|
+
raise ValueError(f"Duplicate field names: {set(duplicates)}")
|
|
316
|
+
return v
|
|
317
|
+
|
|
318
|
+
def get_field(self, name: str) -> FieldSpec | None:
|
|
319
|
+
"""Get a field specification by name.
|
|
320
|
+
|
|
321
|
+
Parameters
|
|
322
|
+
----------
|
|
323
|
+
name : str
|
|
324
|
+
Field name to look up.
|
|
325
|
+
|
|
326
|
+
Returns
|
|
327
|
+
-------
|
|
328
|
+
FieldSpec | None
|
|
329
|
+
The field spec if found, None otherwise.
|
|
330
|
+
|
|
331
|
+
Examples
|
|
332
|
+
--------
|
|
333
|
+
>>> spec = ParticipantMetadataSpec(
|
|
334
|
+
... name="test",
|
|
335
|
+
... fields=[FieldSpec(name="age", field_type="int")]
|
|
336
|
+
... )
|
|
337
|
+
>>> spec.get_field("age").field_type
|
|
338
|
+
'int'
|
|
339
|
+
>>> spec.get_field("unknown") is None
|
|
340
|
+
True
|
|
341
|
+
"""
|
|
342
|
+
for field in self.fields:
|
|
343
|
+
if field.name == name:
|
|
344
|
+
return field
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
def get_required_fields(self) -> list[FieldSpec]:
|
|
348
|
+
"""Get all required field specifications.
|
|
349
|
+
|
|
350
|
+
Returns
|
|
351
|
+
-------
|
|
352
|
+
list[FieldSpec]
|
|
353
|
+
List of required fields.
|
|
354
|
+
|
|
355
|
+
Examples
|
|
356
|
+
--------
|
|
357
|
+
>>> spec = ParticipantMetadataSpec(
|
|
358
|
+
... name="test",
|
|
359
|
+
... fields=[
|
|
360
|
+
... FieldSpec(name="age", field_type="int", required=True),
|
|
361
|
+
... FieldSpec(name="nickname", field_type="str", required=False),
|
|
362
|
+
... ]
|
|
363
|
+
... )
|
|
364
|
+
>>> [f.name for f in spec.get_required_fields()]
|
|
365
|
+
['age']
|
|
366
|
+
"""
|
|
367
|
+
return [f for f in self.fields if f.required]
|
|
368
|
+
|
|
369
|
+
def validate_metadata(
|
|
370
|
+
self, metadata: dict[str, str | int | float | bool | None]
|
|
371
|
+
) -> tuple[bool, list[str]]:
|
|
372
|
+
"""Validate metadata against this specification.
|
|
373
|
+
|
|
374
|
+
Parameters
|
|
375
|
+
----------
|
|
376
|
+
metadata : dict[str, str | int | float | bool | None]
|
|
377
|
+
Metadata dictionary to validate.
|
|
378
|
+
|
|
379
|
+
Returns
|
|
380
|
+
-------
|
|
381
|
+
tuple[bool, list[str]]
|
|
382
|
+
(is_valid, list of error messages). Empty list if valid.
|
|
383
|
+
|
|
384
|
+
Examples
|
|
385
|
+
--------
|
|
386
|
+
>>> spec = ParticipantMetadataSpec(
|
|
387
|
+
... name="test",
|
|
388
|
+
... fields=[
|
|
389
|
+
... FieldSpec(name="age", field_type="int", required=True),
|
|
390
|
+
... ]
|
|
391
|
+
... )
|
|
392
|
+
>>> spec.validate_metadata({"age": 25})
|
|
393
|
+
(True, [])
|
|
394
|
+
>>> spec.validate_metadata({})
|
|
395
|
+
(False, ['Missing required field: age'])
|
|
396
|
+
"""
|
|
397
|
+
errors: list[str] = []
|
|
398
|
+
|
|
399
|
+
# Check required fields
|
|
400
|
+
for field in self.get_required_fields():
|
|
401
|
+
if field.name not in metadata or metadata[field.name] is None:
|
|
402
|
+
errors.append(f"Missing required field: {field.name}")
|
|
403
|
+
|
|
404
|
+
# Validate each provided field
|
|
405
|
+
for key, value in metadata.items():
|
|
406
|
+
field_spec = self.get_field(key)
|
|
407
|
+
if field_spec is None:
|
|
408
|
+
# Allow arbitrary fields not in spec (for flexibility)
|
|
409
|
+
continue
|
|
410
|
+
if not field_spec.validate_value(value):
|
|
411
|
+
range_str = ""
|
|
412
|
+
if field_spec.range is not None:
|
|
413
|
+
range_str = (
|
|
414
|
+
f", range=[{field_spec.range.min}, {field_spec.range.max}]"
|
|
415
|
+
)
|
|
416
|
+
allowed_str = ""
|
|
417
|
+
if field_spec.allowed_values is not None:
|
|
418
|
+
allowed_str = f", allowed={field_spec.allowed_values}"
|
|
419
|
+
errors.append(
|
|
420
|
+
f"Invalid value for {key}: {value!r} "
|
|
421
|
+
f"(expected {field_spec.field_type}{range_str}{allowed_str})"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
return len(errors) == 0, errors
|
|
425
|
+
|
|
426
|
+
def to_demographics_config(self) -> DemographicsConfig:
|
|
427
|
+
"""Convert this spec to a DemographicsConfig for deployment.
|
|
428
|
+
|
|
429
|
+
Creates a demographics form configuration that can be used in
|
|
430
|
+
experiment deployment to collect participant data.
|
|
431
|
+
|
|
432
|
+
Returns
|
|
433
|
+
-------
|
|
434
|
+
DemographicsConfig
|
|
435
|
+
Demographics configuration for jsPsych deployment.
|
|
436
|
+
|
|
437
|
+
Examples
|
|
438
|
+
--------
|
|
439
|
+
>>> spec = ParticipantMetadataSpec(
|
|
440
|
+
... name="test",
|
|
441
|
+
... fields=[
|
|
442
|
+
... FieldSpec(name="age", field_type="int", required=True),
|
|
443
|
+
... ]
|
|
444
|
+
... )
|
|
445
|
+
>>> config = spec.to_demographics_config() # doctest: +SKIP
|
|
446
|
+
>>> config.enabled
|
|
447
|
+
True
|
|
448
|
+
"""
|
|
449
|
+
from bead.deployment.jspsych.config import ( # noqa: PLC0415
|
|
450
|
+
DemographicsConfig,
|
|
451
|
+
DemographicsFieldConfig,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
fields: list[DemographicsFieldConfig] = []
|
|
455
|
+
for field in self.fields:
|
|
456
|
+
# Map field_type to form field_type
|
|
457
|
+
form_field_type: Literal["text", "number", "dropdown", "radio", "checkbox"]
|
|
458
|
+
if field.field_type == "int":
|
|
459
|
+
form_field_type = "number"
|
|
460
|
+
elif field.field_type == "float":
|
|
461
|
+
form_field_type = "number"
|
|
462
|
+
elif field.field_type == "bool":
|
|
463
|
+
form_field_type = "checkbox"
|
|
464
|
+
elif field.allowed_values is not None:
|
|
465
|
+
# Categorical string with options
|
|
466
|
+
form_field_type = "dropdown"
|
|
467
|
+
else:
|
|
468
|
+
form_field_type = "text"
|
|
469
|
+
|
|
470
|
+
# Convert allowed_values to string options for dropdown
|
|
471
|
+
options: list[str] | None = None
|
|
472
|
+
if field.allowed_values is not None:
|
|
473
|
+
options = [str(v) for v in field.allowed_values]
|
|
474
|
+
|
|
475
|
+
fields.append(
|
|
476
|
+
DemographicsFieldConfig(
|
|
477
|
+
name=field.name,
|
|
478
|
+
field_type=form_field_type,
|
|
479
|
+
label=field.get_display_label(),
|
|
480
|
+
required=field.required,
|
|
481
|
+
options=options,
|
|
482
|
+
range=field.range,
|
|
483
|
+
help_text=field.description,
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
return DemographicsConfig(
|
|
488
|
+
enabled=True,
|
|
489
|
+
title="Participant Information",
|
|
490
|
+
fields=fields,
|
|
491
|
+
)
|