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.
Files changed (58) hide show
  1. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/CHANGELOG.md +14 -0
  2. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/PKG-INFO +33 -1
  3. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/README.md +32 -0
  4. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/pyproject.toml +1 -1
  5. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/__init__.py +11 -1
  6. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/experiments.py +5 -0
  7. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/models.py +129 -4
  8. hyperstudy-0.4.3/tests/test_randomization_variables.py +291 -0
  9. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/.github/workflows/publish.yml +0 -0
  10. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/.github/workflows/sync-release-notes.yml +0 -0
  11. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/.github/workflows/test.yml +0 -0
  12. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/.gitignore +0 -0
  13. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/LICENSE +0 -0
  14. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
  15. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/_dataframe.py +0 -0
  16. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/_display.py +0 -0
  17. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/_downloads.py +0 -0
  18. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/_http.py +0 -0
  19. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/_pagination.py +0 -0
  20. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/_types.py +0 -0
  21. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/client.py +0 -0
  22. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/exceptions.py +0 -0
  23. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/src/hyperstudy/personas.py +0 -0
  24. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/__init__.py +0 -0
  25. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/conftest.py +0 -0
  26. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/agent_decision_detail_response.json +0 -0
  27. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/agent_decisions_response.json +0 -0
  28. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/agent_decisions_truncated_response.json +0 -0
  29. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/agent_runs_response.json +0 -0
  30. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/deployment_sessions_response.json +0 -0
  31. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/deployment_single_response.json +0 -0
  32. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/deployments_list_response.json +0 -0
  33. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/error_401.json +0 -0
  34. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/error_403.json +0 -0
  35. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/events_response.json +0 -0
  36. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/experiment.schema.json +0 -0
  37. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/experiment_single_response.json +0 -0
  38. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/experiments_list_response.json +0 -0
  39. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/paginated_page1.json +0 -0
  40. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/paginated_page2.json +0 -0
  41. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/pre_experiment_response.json +0 -0
  42. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/recordings_response.json +0 -0
  43. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/sparse_ratings_response.json +0 -0
  44. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/fixtures/warnings_response.json +0 -0
  45. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_agent_authoring.py +0 -0
  46. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_agent_data.py +0 -0
  47. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_client.py +0 -0
  48. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_dataframe.py +0 -0
  49. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_deployments_write.py +0 -0
  50. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_downloads.py +0 -0
  51. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_experiments.py +0 -0
  52. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_http.py +0 -0
  53. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_models.py +0 -0
  54. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_pagination.py +0 -0
  55. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_personas.py +0 -0
  56. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_recordings_download.py +0 -0
  57. {hyperstudy-0.4.2 → hyperstudy-0.4.3}/tests/test_voice_and_filters.py +0 -0
  58. {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.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperstudy"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "Python SDK for the HyperStudy experiment platform API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.2"
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
- runtime: Optional[str] = None # "v2" required for agent-only deployments
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