hyperstudy 0.4.2__tar.gz → 0.4.3__tar.gz
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.
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/CHANGELOG.md +14 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/PKG-INFO +33 -1
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/README.md +32 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/pyproject.toml +1 -1
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/__init__.py +11 -1
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/experiments.py +5 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/models.py +129 -4
- hyperstudy-0.4.3/tests/test_randomization_variables.py +291 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/.github/workflows/publish.yml +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/.github/workflows/sync-release-notes.yml +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/.github/workflows/test.yml +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/.gitignore +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/LICENSE +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/_dataframe.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/_display.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/_downloads.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/_http.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/_pagination.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/_types.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/client.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/exceptions.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/personas.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/__init__.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/conftest.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/agent_decision_detail_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/agent_decisions_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/agent_decisions_truncated_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/agent_runs_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/deployment_sessions_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/deployment_single_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/deployments_list_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/error_401.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/error_403.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/events_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/experiment.schema.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/experiment_single_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/experiments_list_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/paginated_page1.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/paginated_page2.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/pre_experiment_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/recordings_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/sparse_ratings_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/warnings_response.json +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_agent_authoring.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_agent_data.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_client.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_dataframe.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_deployments_write.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_downloads.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_experiments.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_http.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_models.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_pagination.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_personas.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_recordings_download.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_voice_and_filters.py +0 -0
- {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_wire_keys.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.4.3
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **State randomization (V2 runner)**: `Experiment.clusters` (`Cluster`, `ClusterRule`: `type` `shuffle` | `keep`, `across_groups` `independent` | `same`). States and groups join a cluster with `cluster_id`. Each room draws its order once, so everyone in the room runs the same order. Clusters are used only while `randomize_states=True`, the same setting the legacy runner uses to shuffle each participant's order; with it `False` they are kept but not used.
|
|
8
|
+
- **Variable definitions (V2 runner)**: `Experiment.variable_definitions` (`VariableDefinition` with `kind` `constant`, `random` or `computed`; `Distribution` for random variables: `uniform`, `normal`, `exponential` or `list`, drawn `per_use` or `per_room`). A TIMED state draws its duration from a random variable with `TransitionRules(type="TIMED", variable_id=...)`. Ids are generated when omitted.
|
|
9
|
+
- **`Group`** model for `Experiment.groups` (`id`, `name`, `order`, `children`, `cluster_id`).
|
|
10
|
+
- A constant's `value` and a list distribution's `values` are sent exactly as written on the kwargs and raw-dict paths, like other researcher-named data.
|
|
11
|
+
|
|
12
|
+
### Notes
|
|
13
|
+
|
|
14
|
+
- Both fields need `runtime="v2"`, the server's default for new experiments. The server refuses non-empty `variable_definitions` on any other runtime, and refuses a create or update that would make clusters or variables invalid (`ValidationError`, reasons in `details["errors"]`).
|
|
15
|
+
- Pass `runtime="v2"` to `validate_experiment` when validating variable definitions: servers older than v0.7.32 do not apply create's `"v2"` default in the dry run.
|
|
16
|
+
|
|
3
17
|
## v0.4.2
|
|
4
18
|
|
|
5
19
|
### Breaking Changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hyperstudy
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: Python SDK for the HyperStudy experiment platform API
|
|
5
5
|
Project-URL: Homepage, https://hyperstudy.io
|
|
6
6
|
Project-URL: Documentation, https://docs.hyperstudy.io/developers/python-sdk
|
|
@@ -179,6 +179,38 @@ info = hs.create_experiment(experiment=exp)
|
|
|
179
179
|
|
|
180
180
|
Component factories cover `show_text`, `show_image`, `show_video`, `vas_rating`, `text_input`, `multiple_choice`, `waiting`, `likert_scale`, `ranking`. For other component types, construct directly: `FocusComponent(type=ComponentType.X, config={...})`. See the [Experiment Authoring guide](https://docs.hyperstudy.io/experimenters/api-access/experiment-authoring) for the full reference.
|
|
181
181
|
|
|
182
|
+
### Randomization and variables (V2 runner)
|
|
183
|
+
|
|
184
|
+
Clusters randomize the order of states and groups once per room while `randomize_states=True`; with it `False` the clusters are kept but rooms run the listed order. Variable definitions hold constants, random draws and computed values. Both need `runtime="v2"` (the server's default for new experiments). On the legacy runner, `randomize_states=True` shuffles each participant's order instead.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from hyperstudy import Cluster, ClusterRule, Distribution, TransitionRules, VariableDefinition
|
|
188
|
+
|
|
189
|
+
iti = VariableDefinition(
|
|
190
|
+
name="iti_ms", kind="random",
|
|
191
|
+
distribution=Distribution(type="uniform", min=500, max=1500),
|
|
192
|
+
)
|
|
193
|
+
exp = Experiment(
|
|
194
|
+
name="Randomized study",
|
|
195
|
+
runtime="v2",
|
|
196
|
+
randomize_states=True,
|
|
197
|
+
clusters=[Cluster(id="block", rule=ClusterRule(type="shuffle"))],
|
|
198
|
+
variable_definitions=[
|
|
199
|
+
VariableDefinition(name="endowment", kind="constant", value=10),
|
|
200
|
+
VariableDefinition(name="doubled", kind="computed", expr="endowment * 2"),
|
|
201
|
+
iti,
|
|
202
|
+
],
|
|
203
|
+
states=[
|
|
204
|
+
State(id="a", cluster_id="block", focus_component=show_text("You have {endowment}")),
|
|
205
|
+
State(id="b", cluster_id="block", focus_component=show_text("Doubled: {doubled}")),
|
|
206
|
+
State(id="pause", focus_component=show_text("+"),
|
|
207
|
+
transition_rules=TransitionRules(type="TIMED", variable_id=iti.id)),
|
|
208
|
+
],
|
|
209
|
+
)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The server refuses a create or update that would make clusters or variables invalid, raising `ValidationError` with the reasons in `err.details["errors"]`.
|
|
213
|
+
|
|
182
214
|
## Deployments
|
|
183
215
|
|
|
184
216
|
```python
|
|
@@ -144,6 +144,38 @@ info = hs.create_experiment(experiment=exp)
|
|
|
144
144
|
|
|
145
145
|
Component factories cover `show_text`, `show_image`, `show_video`, `vas_rating`, `text_input`, `multiple_choice`, `waiting`, `likert_scale`, `ranking`. For other component types, construct directly: `FocusComponent(type=ComponentType.X, config={...})`. See the [Experiment Authoring guide](https://docs.hyperstudy.io/experimenters/api-access/experiment-authoring) for the full reference.
|
|
146
146
|
|
|
147
|
+
### Randomization and variables (V2 runner)
|
|
148
|
+
|
|
149
|
+
Clusters randomize the order of states and groups once per room while `randomize_states=True`; with it `False` the clusters are kept but rooms run the listed order. Variable definitions hold constants, random draws and computed values. Both need `runtime="v2"` (the server's default for new experiments). On the legacy runner, `randomize_states=True` shuffles each participant's order instead.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from hyperstudy import Cluster, ClusterRule, Distribution, TransitionRules, VariableDefinition
|
|
153
|
+
|
|
154
|
+
iti = VariableDefinition(
|
|
155
|
+
name="iti_ms", kind="random",
|
|
156
|
+
distribution=Distribution(type="uniform", min=500, max=1500),
|
|
157
|
+
)
|
|
158
|
+
exp = Experiment(
|
|
159
|
+
name="Randomized study",
|
|
160
|
+
runtime="v2",
|
|
161
|
+
randomize_states=True,
|
|
162
|
+
clusters=[Cluster(id="block", rule=ClusterRule(type="shuffle"))],
|
|
163
|
+
variable_definitions=[
|
|
164
|
+
VariableDefinition(name="endowment", kind="constant", value=10),
|
|
165
|
+
VariableDefinition(name="doubled", kind="computed", expr="endowment * 2"),
|
|
166
|
+
iti,
|
|
167
|
+
],
|
|
168
|
+
states=[
|
|
169
|
+
State(id="a", cluster_id="block", focus_component=show_text("You have {endowment}")),
|
|
170
|
+
State(id="b", cluster_id="block", focus_component=show_text("Doubled: {doubled}")),
|
|
171
|
+
State(id="pause", focus_component=show_text("+"),
|
|
172
|
+
transition_rules=TransitionRules(type="TIMED", variable_id=iti.id)),
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The server refuses a create or update that would make clusters or variables invalid, raising `ValidationError` with the reasons in `err.details["errors"]`.
|
|
178
|
+
|
|
147
179
|
## Deployments
|
|
148
180
|
|
|
149
181
|
```python
|
|
@@ -31,13 +31,17 @@ from .exceptions import (
|
|
|
31
31
|
)
|
|
32
32
|
from .models import (
|
|
33
33
|
AgentConfig,
|
|
34
|
+
Cluster,
|
|
35
|
+
ClusterRule,
|
|
34
36
|
ComponentType,
|
|
35
37
|
DisconnectTimeout,
|
|
38
|
+
Distribution,
|
|
36
39
|
EngineTurnDetection,
|
|
37
40
|
Experiment,
|
|
38
41
|
ExperimenterMessageStyle,
|
|
39
42
|
FocusComponent,
|
|
40
43
|
GlobalComponentType,
|
|
44
|
+
Group,
|
|
41
45
|
Guardrails,
|
|
42
46
|
InstructionsPage,
|
|
43
47
|
Persona,
|
|
@@ -49,6 +53,7 @@ from .models import (
|
|
|
49
53
|
Role,
|
|
50
54
|
State,
|
|
51
55
|
TransitionRules,
|
|
56
|
+
VariableDefinition,
|
|
52
57
|
VoiceTranscription,
|
|
53
58
|
VoiceTurnTaking,
|
|
54
59
|
WaitingRoomConfig,
|
|
@@ -63,7 +68,7 @@ from .models import (
|
|
|
63
68
|
waiting,
|
|
64
69
|
)
|
|
65
70
|
|
|
66
|
-
__version__ = "0.4.
|
|
71
|
+
__version__ = "0.4.3"
|
|
67
72
|
|
|
68
73
|
__all__ = [
|
|
69
74
|
"HyperStudy",
|
|
@@ -98,6 +103,11 @@ __all__ = [
|
|
|
98
103
|
"VoiceTranscription",
|
|
99
104
|
"ExperimenterMessageStyle",
|
|
100
105
|
"TransitionRules",
|
|
106
|
+
"Group",
|
|
107
|
+
"Cluster",
|
|
108
|
+
"ClusterRule",
|
|
109
|
+
"VariableDefinition",
|
|
110
|
+
"Distribution",
|
|
101
111
|
"WaitingRoomConfig",
|
|
102
112
|
"DisconnectTimeout",
|
|
103
113
|
"InstructionsPage",
|
|
@@ -211,6 +211,11 @@ class ExperimentMixin:
|
|
|
211
211
|
``{"valid": True}`` on success, or ``{"valid": False, "errors": [...]}``
|
|
212
212
|
with a list of issues.
|
|
213
213
|
|
|
214
|
+
Set ``runtime`` explicitly when the definition has
|
|
215
|
+
``variable_definitions``: servers older than v0.7.32 do not default
|
|
216
|
+
it to ``"v2"`` here the way :meth:`create_experiment` does, and
|
|
217
|
+
report variable definitions on any other runtime as an error.
|
|
218
|
+
|
|
214
219
|
Args:
|
|
215
220
|
experiment: An :class:`Experiment` or a raw config dict.
|
|
216
221
|
"""
|
|
@@ -37,10 +37,13 @@ _FREEFORM_MAP_FIELDS = frozenset({
|
|
|
37
37
|
# (engineTurnDetection), and names the researcher chose (cognition contexts,
|
|
38
38
|
# stimulus mappings, free-form metadata). Matching on position, not the bare
|
|
39
39
|
# key, keeps a variable or role that happens to be named "metadata" a variable.
|
|
40
|
+
# A variable definition's constant ``value`` and a list distribution's
|
|
41
|
+
# ``values`` are researcher data, so their keys are kept too.
|
|
40
42
|
_VERBATIM_FIELDS = frozenset({
|
|
41
43
|
(None, "extraParams"), (None, "cognition"), (None, "stimulusMappings"),
|
|
42
44
|
(None, "metadata"), (None, "prolific_config"),
|
|
43
45
|
("voiceTurnTaking", "engineTurnDetection"),
|
|
46
|
+
("variableDefinitions", "value"), ("distribution", "values"),
|
|
44
47
|
})
|
|
45
48
|
|
|
46
49
|
# Experiment fields the backend stores under snake_case names.
|
|
@@ -151,14 +154,23 @@ class FocusComponent(_Model):
|
|
|
151
154
|
|
|
152
155
|
|
|
153
156
|
class TransitionRules(_Model):
|
|
154
|
-
"""Rules governing how the experiment transitions from a state.
|
|
157
|
+
"""Rules governing how the experiment transitions from a state.
|
|
158
|
+
|
|
159
|
+
On the V2 runner a ``"TIMED"`` state can draw its duration from a random
|
|
160
|
+
variable instead of a fixed value: set ``variable_id`` to the id of a
|
|
161
|
+
:class:`VariableDefinition` with ``kind="random"``.
|
|
162
|
+
"""
|
|
155
163
|
|
|
156
164
|
type: Optional[str] = None
|
|
157
165
|
duration_ms: Optional[int] = Field(default=None, ge=0)
|
|
166
|
+
variable_id: Optional[str] = None
|
|
158
167
|
|
|
159
168
|
|
|
160
169
|
class State(_Model):
|
|
161
|
-
"""An experiment state — a single screen/phase.
|
|
170
|
+
"""An experiment state — a single screen/phase.
|
|
171
|
+
|
|
172
|
+
``cluster_id`` (V2 runner) makes the state a member of a :class:`Cluster`.
|
|
173
|
+
"""
|
|
162
174
|
|
|
163
175
|
id: str = Field(min_length=1)
|
|
164
176
|
name: Optional[str] = None
|
|
@@ -166,6 +178,108 @@ class State(_Model):
|
|
|
166
178
|
focus_component: Optional[FocusComponent] = None
|
|
167
179
|
transition_rules: Optional[TransitionRules] = None
|
|
168
180
|
global_components_visibility: Optional[dict[str, bool]] = None
|
|
181
|
+
cluster_id: Optional[str] = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Group(_Model):
|
|
185
|
+
"""A group of states that run together.
|
|
186
|
+
|
|
187
|
+
``children`` are state ids, in run order. ``order`` places the group
|
|
188
|
+
among the top-level states (lone states and groups share one sequence).
|
|
189
|
+
``cluster_id`` (V2 runner) makes the whole group a member of a
|
|
190
|
+
:class:`Cluster`, moving as one unit; its states can carry their own
|
|
191
|
+
``cluster_id`` to be randomized within the group.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
id: str = Field(min_length=1)
|
|
195
|
+
name: Optional[str] = None
|
|
196
|
+
order: Optional[int] = Field(default=None, ge=0)
|
|
197
|
+
children: Optional[list[str]] = None
|
|
198
|
+
cluster_id: Optional[str] = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class ClusterRule(_Model):
|
|
202
|
+
"""How a cluster orders its members.
|
|
203
|
+
|
|
204
|
+
``type``: ``"shuffle"`` (a uniformly random order) or ``"keep"`` (the
|
|
205
|
+
authored order). ``across_groups``, for a cluster used inside groups:
|
|
206
|
+
``"independent"`` (default; each group draws its own order) or
|
|
207
|
+
``"same"`` (one order applied to every group using the cluster, which
|
|
208
|
+
must then all have the same number of members).
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
type: str # "shuffle" | "keep"
|
|
212
|
+
across_groups: Optional[str] = None # "independent" (default) | "same"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class Cluster(_Model):
|
|
216
|
+
"""A randomization cluster (V2 runner only; ignored on the legacy runner).
|
|
217
|
+
|
|
218
|
+
Used only while the experiment's ``randomize_states`` is True; with it
|
|
219
|
+
False the clusters are kept but rooms run the listed order.
|
|
220
|
+
|
|
221
|
+
States and groups join a cluster by setting ``cluster_id`` to its ``id``.
|
|
222
|
+
A cluster applies among siblings: the top-level items (lone states and
|
|
223
|
+
whole groups), or the states inside one group. Members trade places
|
|
224
|
+
among the positions they occupy; everything else stays where it is. Each
|
|
225
|
+
room draws its order once, so everyone in the room runs the same order.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
id: str = Field(default_factory=_new_id, min_length=1)
|
|
229
|
+
name: Optional[str] = None
|
|
230
|
+
color: Optional[str] = None
|
|
231
|
+
rule: ClusterRule
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class Distribution(_Model):
|
|
235
|
+
"""The distribution a random variable draws from.
|
|
236
|
+
|
|
237
|
+
- ``type="uniform"``: ``min``, ``max``
|
|
238
|
+
- ``type="normal"``: ``mean``, ``sd``; optional ``min`` / ``max`` truncate it
|
|
239
|
+
- ``type="exponential"``: ``mean``; ``min`` (default 0) plus an exponential
|
|
240
|
+
with this mean, optionally cut at ``max``
|
|
241
|
+
- ``type="list"``: ``values`` and ``sampling`` (``"with_replacement"``,
|
|
242
|
+
``"without_replacement"`` — a deck reshuffled when exhausted — or
|
|
243
|
+
``"sequential"``, in order, wrapping)
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
type: str # "uniform" | "normal" | "exponential" | "list"
|
|
247
|
+
min: Optional[float] = None
|
|
248
|
+
max: Optional[float] = None
|
|
249
|
+
mean: Optional[float] = None
|
|
250
|
+
sd: Optional[float] = Field(default=None, gt=0)
|
|
251
|
+
values: Optional[list[Any]] = Field(default=None, min_length=1, max_length=1000)
|
|
252
|
+
sampling: Optional[str] = None # list: with_replacement | without_replacement | sequential
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class VariableDefinition(_Model):
|
|
256
|
+
"""A variable the experiment defines (V2 runner only).
|
|
257
|
+
|
|
258
|
+
``kind`` decides which fields apply:
|
|
259
|
+
|
|
260
|
+
- ``"constant"``: ``value`` (text, a number, true/false, a list or a dict)
|
|
261
|
+
- ``"random"``: ``distribution``, ``draw`` (``"per_use"``, the default: a
|
|
262
|
+
fresh value for each state that takes its duration from it;
|
|
263
|
+
``"per_room"``: one value for the room) and ``round`` (decimals kept,
|
|
264
|
+
default 0). Values are drawn when the room is planned, so everyone in
|
|
265
|
+
the room gets the same ones.
|
|
266
|
+
- ``"computed"``: ``expr``, an expression over other variables
|
|
267
|
+
|
|
268
|
+
``name`` is what component text (``{name}``) and expressions read; ``id``
|
|
269
|
+
is what a TIMED state's ``TransitionRules.variable_id`` references, and is
|
|
270
|
+
generated when omitted. The server refuses non-empty definitions unless
|
|
271
|
+
the experiment's ``runtime`` is ``"v2"``, and refuses a save that makes
|
|
272
|
+
them invalid (``ValidationError``, messages in ``details["errors"]``).
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
id: str = Field(default_factory=_new_id, min_length=1)
|
|
276
|
+
name: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|
277
|
+
kind: str # "constant" | "random" | "computed"
|
|
278
|
+
value: Optional[Any] = None
|
|
279
|
+
distribution: Optional[Distribution] = None
|
|
280
|
+
draw: Optional[str] = None # random: "per_use" (default) | "per_room"
|
|
281
|
+
round: Optional[int] = Field(default=None, ge=0, le=6)
|
|
282
|
+
expr: Optional[str] = Field(default=None, min_length=1, max_length=2000)
|
|
169
283
|
|
|
170
284
|
|
|
171
285
|
class Role(_Model):
|
|
@@ -371,10 +485,15 @@ class Experiment(_Model):
|
|
|
371
485
|
name: str = Field(min_length=1)
|
|
372
486
|
description: Optional[str] = None
|
|
373
487
|
required_participants: Optional[int] = Field(default=None, ge=1)
|
|
374
|
-
|
|
488
|
+
# "v2" (the server's default for new experiments) or "v1" (legacy).
|
|
489
|
+
# Agents, clusters and variable_definitions need "v2".
|
|
490
|
+
runtime: Optional[str] = None
|
|
375
491
|
agent_config: Optional[AgentConfig] = None
|
|
376
|
-
randomize_states: Optional[bool] = None
|
|
492
|
+
randomize_states: Optional[bool] = None # legacy: shuffle per participant; v2: use clusters
|
|
377
493
|
states: Optional[list[State]] = None
|
|
494
|
+
groups: Optional[list[Group]] = None
|
|
495
|
+
clusters: Optional[list[Cluster]] = Field(default=None, max_length=100)
|
|
496
|
+
variable_definitions: Optional[list[VariableDefinition]] = Field(default=None, max_length=500)
|
|
378
497
|
roles: Optional[dict[str, Role]] = None
|
|
379
498
|
global_components: Optional[dict[GlobalComponentType, dict[str, Any]]] = None
|
|
380
499
|
variables: Optional[dict[str, Any]] = None
|
|
@@ -561,12 +680,18 @@ __all__ = [
|
|
|
561
680
|
"State",
|
|
562
681
|
"FocusComponent",
|
|
563
682
|
"TransitionRules",
|
|
683
|
+
"Group",
|
|
564
684
|
"Role",
|
|
565
685
|
"WaitingRoomConfig",
|
|
566
686
|
"DisconnectTimeout",
|
|
567
687
|
"InstructionsPage",
|
|
568
688
|
"PostExperimentQuestionnaire",
|
|
569
689
|
"ExperimenterMessageStyle",
|
|
690
|
+
# Randomization and variables (V2 runner)
|
|
691
|
+
"Cluster",
|
|
692
|
+
"ClusterRule",
|
|
693
|
+
"VariableDefinition",
|
|
694
|
+
"Distribution",
|
|
570
695
|
# Agent models
|
|
571
696
|
"AgentConfig",
|
|
572
697
|
"PromptLayer",
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""V2 randomization clusters and variable definitions on experiments.
|
|
2
|
+
|
|
3
|
+
The v3 API accepts ``clusters`` and ``variableDefinitions`` on create and
|
|
4
|
+
update, states and groups join a cluster with ``clusterId``, and a TIMED state
|
|
5
|
+
draws its duration from a random variable with ``transitionRules.variableId``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
import responses
|
|
14
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
15
|
+
|
|
16
|
+
from hyperstudy import (
|
|
17
|
+
Cluster,
|
|
18
|
+
ClusterRule,
|
|
19
|
+
Distribution,
|
|
20
|
+
Experiment,
|
|
21
|
+
Group,
|
|
22
|
+
HyperStudy,
|
|
23
|
+
State,
|
|
24
|
+
TransitionRules,
|
|
25
|
+
VariableDefinition,
|
|
26
|
+
show_text,
|
|
27
|
+
)
|
|
28
|
+
from hyperstudy.exceptions import ValidationError
|
|
29
|
+
|
|
30
|
+
from .conftest import BASE_URL, envelope
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _sent(call_index: int = 0) -> dict:
|
|
34
|
+
return json.loads(responses.calls[call_index].request.body)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _randomized_experiment() -> Experiment:
|
|
38
|
+
return Experiment(
|
|
39
|
+
name="Randomized study",
|
|
40
|
+
runtime="v2",
|
|
41
|
+
randomize_states=True,
|
|
42
|
+
clusters=[
|
|
43
|
+
Cluster(id="block", name="Block", rule=ClusterRule(type="shuffle")),
|
|
44
|
+
Cluster(id="inner", rule=ClusterRule(type="shuffle", across_groups="same")),
|
|
45
|
+
],
|
|
46
|
+
variable_definitions=[
|
|
47
|
+
VariableDefinition(
|
|
48
|
+
id="v_end", name="endowment", kind="constant", value={"first_offer": 3}
|
|
49
|
+
),
|
|
50
|
+
VariableDefinition(
|
|
51
|
+
id="v_iti",
|
|
52
|
+
name="iti_ms",
|
|
53
|
+
kind="random",
|
|
54
|
+
distribution=Distribution(type="uniform", min=500, max=1500),
|
|
55
|
+
draw="per_use",
|
|
56
|
+
round=0,
|
|
57
|
+
),
|
|
58
|
+
VariableDefinition(
|
|
59
|
+
id="v_pick",
|
|
60
|
+
name="pick",
|
|
61
|
+
kind="random",
|
|
62
|
+
distribution=Distribution(
|
|
63
|
+
type="list",
|
|
64
|
+
values=[{"face_id": 1}, {"face_id": 2}],
|
|
65
|
+
sampling="without_replacement",
|
|
66
|
+
),
|
|
67
|
+
draw="per_room",
|
|
68
|
+
),
|
|
69
|
+
VariableDefinition(id="v_dbl", name="doubled", kind="computed", expr="endowment * 2"),
|
|
70
|
+
],
|
|
71
|
+
states=[
|
|
72
|
+
State(id="a", cluster_id="block", focus_component=show_text("A", id="fa")),
|
|
73
|
+
State(id="b", cluster_id="block", focus_component=show_text("B", id="fb")),
|
|
74
|
+
State(
|
|
75
|
+
id="pause",
|
|
76
|
+
focus_component=show_text("+", id="fp"),
|
|
77
|
+
transition_rules=TransitionRules(type="TIMED", variable_id="v_iti"),
|
|
78
|
+
),
|
|
79
|
+
State(id="g1", cluster_id="inner"),
|
|
80
|
+
State(id="g2", cluster_id="inner"),
|
|
81
|
+
],
|
|
82
|
+
groups=[Group(id="grp", name="Group", order=3, children=["g1", "g2"], cluster_id="block")],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@responses.activate
|
|
87
|
+
def test_create_sends_clusters_and_variable_definitions_in_wire_format(api_key):
|
|
88
|
+
responses.post(f"{BASE_URL}/experiments", json=envelope({"id": "exp-1"}), status=201)
|
|
89
|
+
|
|
90
|
+
HyperStudy(api_key=api_key).create_experiment(experiment=_randomized_experiment())
|
|
91
|
+
sent = _sent()
|
|
92
|
+
|
|
93
|
+
assert sent["runtime"] == "v2"
|
|
94
|
+
assert sent["randomizeStates"] is True
|
|
95
|
+
assert sent["clusters"] == [
|
|
96
|
+
{"id": "block", "name": "Block", "rule": {"type": "shuffle"}},
|
|
97
|
+
{"id": "inner", "rule": {"type": "shuffle", "acrossGroups": "same"}},
|
|
98
|
+
]
|
|
99
|
+
by_id = {v["id"]: v for v in sent["variableDefinitions"]}
|
|
100
|
+
# A constant's value is researcher data: its keys are not camelCased.
|
|
101
|
+
assert by_id["v_end"] == {
|
|
102
|
+
"id": "v_end",
|
|
103
|
+
"name": "endowment",
|
|
104
|
+
"kind": "constant",
|
|
105
|
+
"value": {"first_offer": 3},
|
|
106
|
+
}
|
|
107
|
+
assert by_id["v_iti"] == {
|
|
108
|
+
"id": "v_iti",
|
|
109
|
+
"name": "iti_ms",
|
|
110
|
+
"kind": "random",
|
|
111
|
+
"distribution": {"type": "uniform", "min": 500, "max": 1500},
|
|
112
|
+
"draw": "per_use",
|
|
113
|
+
"round": 0,
|
|
114
|
+
}
|
|
115
|
+
assert by_id["v_pick"]["distribution"]["values"] == [{"face_id": 1}, {"face_id": 2}]
|
|
116
|
+
assert by_id["v_dbl"] == {
|
|
117
|
+
"id": "v_dbl",
|
|
118
|
+
"name": "doubled",
|
|
119
|
+
"kind": "computed",
|
|
120
|
+
"expr": "endowment * 2",
|
|
121
|
+
}
|
|
122
|
+
assert "variable_definitions" not in sent
|
|
123
|
+
|
|
124
|
+
states = {s["id"]: s for s in sent["states"]}
|
|
125
|
+
assert states["a"]["clusterId"] == "block"
|
|
126
|
+
assert states["pause"]["transitionRules"] == {"type": "TIMED", "variableId": "v_iti"}
|
|
127
|
+
assert sent["groups"] == [
|
|
128
|
+
{"id": "grp", "name": "Group", "order": 3, "children": ["g1", "g2"], "clusterId": "block"}
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@responses.activate
|
|
133
|
+
def test_update_kwargs_with_plain_dicts_convert_schema_keys_only(api_key):
|
|
134
|
+
"""Kwargs camelCase schema keys but keep a constant's value and list values as written."""
|
|
135
|
+
responses.put(
|
|
136
|
+
f"{BASE_URL}/experiments/exp-1", json=envelope({"id": "exp-1", "updated": True}), status=200
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
result = HyperStudy(api_key=api_key).update_experiment(
|
|
140
|
+
"exp-1",
|
|
141
|
+
clusters=[{"id": "block", "rule": {"type": "keep", "across_groups": "independent"}}],
|
|
142
|
+
variable_definitions=[
|
|
143
|
+
{
|
|
144
|
+
"id": "v1",
|
|
145
|
+
"name": "prices",
|
|
146
|
+
"kind": "constant",
|
|
147
|
+
"value": {"base_price": 2, "max_price": 9},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"id": "v2",
|
|
151
|
+
"name": "cue",
|
|
152
|
+
"kind": "random",
|
|
153
|
+
"draw": "per_room",
|
|
154
|
+
"distribution": {
|
|
155
|
+
"type": "list",
|
|
156
|
+
"values": [{"cue_side": "left"}],
|
|
157
|
+
"sampling": "sequential",
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
assert result == {"id": "exp-1", "updated": True}
|
|
164
|
+
assert _sent() == {
|
|
165
|
+
"clusters": [{"id": "block", "rule": {"type": "keep", "acrossGroups": "independent"}}],
|
|
166
|
+
"variableDefinitions": [
|
|
167
|
+
{
|
|
168
|
+
"id": "v1",
|
|
169
|
+
"name": "prices",
|
|
170
|
+
"kind": "constant",
|
|
171
|
+
"value": {"base_price": 2, "max_price": 9},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"id": "v2",
|
|
175
|
+
"name": "cue",
|
|
176
|
+
"kind": "random",
|
|
177
|
+
"draw": "per_room",
|
|
178
|
+
"distribution": {
|
|
179
|
+
"type": "list",
|
|
180
|
+
"values": [{"cue_side": "left"}],
|
|
181
|
+
"sampling": "sequential",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@responses.activate
|
|
189
|
+
def test_update_kwargs_accept_typed_models(api_key):
|
|
190
|
+
responses.put(
|
|
191
|
+
f"{BASE_URL}/experiments/exp-1", json=envelope({"id": "exp-1", "updated": True}), status=200
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
HyperStudy(api_key=api_key).update_experiment(
|
|
195
|
+
"exp-1",
|
|
196
|
+
clusters=[Cluster(id="c1", rule=ClusterRule(type="shuffle", across_groups="same"))],
|
|
197
|
+
variable_definitions=[VariableDefinition(id="v1", name="n", kind="constant", value=[1, 2])],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
assert _sent() == {
|
|
201
|
+
"clusters": [{"id": "c1", "rule": {"type": "shuffle", "acrossGroups": "same"}}],
|
|
202
|
+
"variableDefinitions": [{"id": "v1", "name": "n", "kind": "constant", "value": [1, 2]}],
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@responses.activate
|
|
207
|
+
def test_get_config_round_trips_through_the_typed_builder(api_key):
|
|
208
|
+
"""A definition read back from /config rebuilds into typed models and re-sends unchanged."""
|
|
209
|
+
stored = Experiment.model_validate(
|
|
210
|
+
_randomized_experiment().model_dump(by_alias=True, exclude_none=True)
|
|
211
|
+
)
|
|
212
|
+
record = {"id": "exp-1", **stored.model_dump(by_alias=True, exclude_none=True)}
|
|
213
|
+
responses.get(f"{BASE_URL}/experiments/exp-1/config", json=envelope(record), status=200)
|
|
214
|
+
|
|
215
|
+
config = HyperStudy(api_key=api_key).get_experiment_config("exp-1")
|
|
216
|
+
|
|
217
|
+
assert config["clusters"][1]["rule"]["acrossGroups"] == "same"
|
|
218
|
+
rebuilt = Experiment.model_validate(config)
|
|
219
|
+
assert isinstance(rebuilt.clusters[0], Cluster)
|
|
220
|
+
assert isinstance(rebuilt.variable_definitions[1], VariableDefinition)
|
|
221
|
+
assert isinstance(rebuilt.variable_definitions[1].distribution, Distribution)
|
|
222
|
+
assert rebuilt.states[2].transition_rules.variable_id == "v_iti"
|
|
223
|
+
assert rebuilt.groups[0].cluster_id == "block"
|
|
224
|
+
assert rebuilt.variable_definitions[0].value == {"first_offer": 3}
|
|
225
|
+
dumped = rebuilt.model_dump(by_alias=True, exclude_none=True)
|
|
226
|
+
dumped.pop("id")
|
|
227
|
+
assert dumped == stored.model_dump(by_alias=True, exclude_none=True)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@responses.activate
|
|
231
|
+
def test_refused_randomization_raises_validation_error_with_reasons(api_key):
|
|
232
|
+
responses.put(
|
|
233
|
+
f"{BASE_URL}/experiments/exp-1",
|
|
234
|
+
json={
|
|
235
|
+
"status": "error",
|
|
236
|
+
"error": {
|
|
237
|
+
"code": "VALIDATION_ERROR",
|
|
238
|
+
"message": "Invalid randomization",
|
|
239
|
+
"details": {"errors": ['cluster "c1" has unknown rule "sort"']},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
status=400,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
with pytest.raises(ValidationError) as caught:
|
|
246
|
+
HyperStudy(api_key=api_key).update_experiment(
|
|
247
|
+
"exp-1", clusters=[{"id": "c1", "rule": {"type": "sort"}}]
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
assert caught.value.code == "VALIDATION_ERROR"
|
|
251
|
+
assert caught.value.details["errors"] == ['cluster "c1" has unknown rule "sort"']
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_generated_ids_let_a_timed_state_reference_its_variable():
|
|
255
|
+
iti = VariableDefinition(
|
|
256
|
+
name="iti_ms",
|
|
257
|
+
kind="random",
|
|
258
|
+
distribution=Distribution(type="exponential", mean=800, max=3000),
|
|
259
|
+
)
|
|
260
|
+
cluster = Cluster(rule=ClusterRule(type="shuffle"))
|
|
261
|
+
assert iti.id and cluster.id and iti.id != cluster.id
|
|
262
|
+
state = State(id="s", transition_rules=TransitionRules(type="TIMED", variable_id=iti.id))
|
|
263
|
+
assert (
|
|
264
|
+
state.model_dump(by_alias=True, exclude_none=True)["transitionRules"]["variableId"]
|
|
265
|
+
== iti.id
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@pytest.mark.parametrize(
|
|
270
|
+
"kwargs",
|
|
271
|
+
[
|
|
272
|
+
{"name": "1st", "kind": "constant", "value": 1}, # name starts with a digit
|
|
273
|
+
{"name": "x", "kind": "random", "round": 7}, # more decimals than the server keeps
|
|
274
|
+
{"name": "x", "kind": "computed", "expr": ""}, # empty expression
|
|
275
|
+
],
|
|
276
|
+
)
|
|
277
|
+
def test_variable_definition_rejects_what_the_server_would(kwargs):
|
|
278
|
+
with pytest.raises(PydanticValidationError):
|
|
279
|
+
VariableDefinition(**kwargs)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_distribution_rejects_non_positive_sd_and_empty_list():
|
|
283
|
+
with pytest.raises(PydanticValidationError):
|
|
284
|
+
Distribution(type="normal", mean=0, sd=0)
|
|
285
|
+
with pytest.raises(PydanticValidationError):
|
|
286
|
+
Distribution(type="list", values=[], sampling="sequential")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_too_many_clusters_fail_when_the_model_is_built():
|
|
290
|
+
with pytest.raises(PydanticValidationError):
|
|
291
|
+
Experiment(name="x", clusters=[Cluster(rule=ClusterRule(type="keep")) for _ in range(101)])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/agent_decisions_truncated_response.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|