hyperstudy 0.4.0__tar.gz → 0.5.0__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 (54) hide show
  1. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/CHANGELOG.md +14 -0
  2. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/PKG-INFO +17 -3
  3. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/README.md +16 -2
  4. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/pyproject.toml +1 -1
  5. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/src/hyperstudy/__init__.py +1 -1
  6. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/src/hyperstudy/_types.py +1 -0
  7. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/src/hyperstudy/client.py +91 -0
  8. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/src/hyperstudy/experiments.py +11 -0
  9. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/src/hyperstudy/models.py +3 -3
  10. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/src/hyperstudy/personas.py +18 -0
  11. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/test_client.py +123 -2
  12. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/test_experiments.py +32 -0
  13. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/test_personas.py +70 -0
  14. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/.github/workflows/publish.yml +0 -0
  15. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/.github/workflows/sync-release-notes.yml +0 -0
  16. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/.github/workflows/test.yml +0 -0
  17. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/.gitignore +0 -0
  18. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/LICENSE +0 -0
  19. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
  20. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/src/hyperstudy/_dataframe.py +0 -0
  21. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/src/hyperstudy/_display.py +0 -0
  22. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/src/hyperstudy/_downloads.py +0 -0
  23. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/src/hyperstudy/_http.py +0 -0
  24. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/src/hyperstudy/_pagination.py +0 -0
  25. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/src/hyperstudy/exceptions.py +0 -0
  26. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/__init__.py +0 -0
  27. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/conftest.py +0 -0
  28. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/agent_decision_detail_response.json +0 -0
  29. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/agent_decisions_response.json +0 -0
  30. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/agent_decisions_truncated_response.json +0 -0
  31. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/agent_runs_response.json +0 -0
  32. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/deployment_sessions_response.json +0 -0
  33. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/deployment_single_response.json +0 -0
  34. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/deployments_list_response.json +0 -0
  35. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/error_401.json +0 -0
  36. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/error_403.json +0 -0
  37. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/events_response.json +0 -0
  38. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/experiment.schema.json +0 -0
  39. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/experiment_single_response.json +0 -0
  40. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/experiments_list_response.json +0 -0
  41. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/paginated_page1.json +0 -0
  42. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/paginated_page2.json +0 -0
  43. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/pre_experiment_response.json +0 -0
  44. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/recordings_response.json +0 -0
  45. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/sparse_ratings_response.json +0 -0
  46. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/fixtures/warnings_response.json +0 -0
  47. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/test_agent_data.py +0 -0
  48. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/test_dataframe.py +0 -0
  49. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/test_deployments_write.py +0 -0
  50. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/test_downloads.py +0 -0
  51. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/test_http.py +0 -0
  52. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/test_models.py +0 -0
  53. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/test_pagination.py +0 -0
  54. {hyperstudy-0.4.0 → hyperstudy-0.5.0}/tests/test_recordings_download.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.5.0
4
+
5
+ ### Breaking Changes
6
+
7
+ - **Removed `Persona.offline_cognition`.** The server now hard-discards this field (`offlineCognition` is always null server-side); offline loops live inside `cognition.config.contexts.<name>.offline`. The SDK also strips `offlineCognition` from create/update payloads so personas rebuilt from older API responses never send it.
8
+
9
+ ### Features
10
+
11
+ - **Eye-tracking data access**: `get_eyetracking(scope_id, scope="experiment"|"room"|"participant", room_id=)` with the standard pagination/output options, plus `DataType.EYETRACKING`. `get_all_data` now includes an `eyetracking` key.
12
+ - **Room variables**: `get_variables(room_id)` — the reconstructed shared-variable timeline for a room. Returns `{writes, timeline, variable_names, dropped_writes}`: the write log (each write tagged with source and persisted flag), the per-state forward-filled snapshot matrix, the ordered variable set, and any writes that failed the server's ground-truth cross-checks (a bug signal).
13
+ - **Participant counts**: `get_counts(participant_id, room_id=)` — cheap per-data-type document counts (`counts` / `hasData`) for a participant in a room.
14
+ - **Experiment export**: `export_experiment(experiment_id)` — portable experiment JSON (definition plus media-asset manifest), suitable for re-import.
15
+ - **Cognition catalog**: `get_cognition_catalog()` — the valid building blocks for persona `cognition` configs (`abilities`, `recipes`, `offlineRecipes`). Requires the `read:personas` scope.
16
+
3
17
  ## v0.4.0
4
18
 
5
19
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperstudy
3
- Version: 0.4.0
3
+ Version: 0.5.0
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
@@ -78,6 +78,7 @@ chat = hs.get_chat("exp_id")
78
78
  videochat = hs.get_videochat("exp_id")
79
79
  sync = hs.get_sync("exp_id")
80
80
  ratings = hs.get_ratings("exp_id", kind="continuous")
81
+ eyetracking = hs.get_eyetracking("exp_id")
81
82
  components = hs.get_components("exp_id")
82
83
  participants = hs.get_participants("exp_id")
83
84
  rooms = hs.get_rooms("exp_id")
@@ -86,6 +87,13 @@ rooms = hs.get_rooms("exp_id")
86
87
  questionnaire = hs.get_questionnaire("exp_id")
87
88
  instructions = hs.get_instructions("exp_id")
88
89
  consent = hs.get_consent("exp_id")
90
+
91
+ # Room-scoped: reconstructed shared-variable timeline
92
+ # -> {"writes", "timeline", "variable_names", "dropped_writes"}
93
+ variables = hs.get_variables("room_id")
94
+
95
+ # Per-data-type document counts for one participant in a room
96
+ counts = hs.get_counts("participant_id", room_id="room_id")
89
97
  ```
90
98
 
91
99
  ### Output Formats
@@ -130,6 +138,9 @@ exp = hs.get_experiment("exp_id")
130
138
  new_exp = hs.create_experiment(name="My Study", description="...")
131
139
  hs.update_experiment("exp_id", name="Updated Name")
132
140
  hs.delete_experiment("exp_id")
141
+
142
+ # Portable experiment JSON (definition + media-asset manifest)
143
+ export = hs.export_experiment("exp_id")
133
144
  ```
134
145
 
135
146
  ### Building experiments programmatically (0.3.0+)
@@ -215,6 +226,9 @@ hs.run_more(dep["id"], rooms=5, budget_usd=2.5) # additive batch
215
226
  decisions = hs.get_agent_decisions(exp["id"]) # per-turn logs
216
227
  runs = hs.get_agent_runs(exp["id"]) # run manifests
217
228
  detail = hs.get_agent_decision("room_id", "participantId_3") # full blobs
229
+
230
+ # --- Cognition authoring catalog ---
231
+ catalog = hs.get_cognition_catalog() # {"abilities": ..., "recipes": ..., "offlineRecipes": ...}
218
232
  ```
219
233
 
220
234
  Persona methods need the `read:personas` / `write:personas` API-key scopes; deployment launch/control needs `write:deployments`; agent data reads need `read:events`.
@@ -224,8 +238,8 @@ Persona methods need the `read:personas` / `write:personas` API-key scopes; depl
224
238
  ```python
225
239
  data = hs.get_all_data("participant_id", room_id="room_id")
226
240
  # Returns dict with keys: events, recordings, chat, videochat, sync,
227
- # ratings_continuous, ratings_sparse, components, questionnaire,
228
- # instructions, consent, agent_decisions
241
+ # ratings_continuous, ratings_sparse, components, eyetracking,
242
+ # questionnaire, instructions, consent, agent_decisions
229
243
  ```
230
244
 
231
245
  ## API Key
@@ -43,6 +43,7 @@ chat = hs.get_chat("exp_id")
43
43
  videochat = hs.get_videochat("exp_id")
44
44
  sync = hs.get_sync("exp_id")
45
45
  ratings = hs.get_ratings("exp_id", kind="continuous")
46
+ eyetracking = hs.get_eyetracking("exp_id")
46
47
  components = hs.get_components("exp_id")
47
48
  participants = hs.get_participants("exp_id")
48
49
  rooms = hs.get_rooms("exp_id")
@@ -51,6 +52,13 @@ rooms = hs.get_rooms("exp_id")
51
52
  questionnaire = hs.get_questionnaire("exp_id")
52
53
  instructions = hs.get_instructions("exp_id")
53
54
  consent = hs.get_consent("exp_id")
55
+
56
+ # Room-scoped: reconstructed shared-variable timeline
57
+ # -> {"writes", "timeline", "variable_names", "dropped_writes"}
58
+ variables = hs.get_variables("room_id")
59
+
60
+ # Per-data-type document counts for one participant in a room
61
+ counts = hs.get_counts("participant_id", room_id="room_id")
54
62
  ```
55
63
 
56
64
  ### Output Formats
@@ -95,6 +103,9 @@ exp = hs.get_experiment("exp_id")
95
103
  new_exp = hs.create_experiment(name="My Study", description="...")
96
104
  hs.update_experiment("exp_id", name="Updated Name")
97
105
  hs.delete_experiment("exp_id")
106
+
107
+ # Portable experiment JSON (definition + media-asset manifest)
108
+ export = hs.export_experiment("exp_id")
98
109
  ```
99
110
 
100
111
  ### Building experiments programmatically (0.3.0+)
@@ -180,6 +191,9 @@ hs.run_more(dep["id"], rooms=5, budget_usd=2.5) # additive batch
180
191
  decisions = hs.get_agent_decisions(exp["id"]) # per-turn logs
181
192
  runs = hs.get_agent_runs(exp["id"]) # run manifests
182
193
  detail = hs.get_agent_decision("room_id", "participantId_3") # full blobs
194
+
195
+ # --- Cognition authoring catalog ---
196
+ catalog = hs.get_cognition_catalog() # {"abilities": ..., "recipes": ..., "offlineRecipes": ...}
183
197
  ```
184
198
 
185
199
  Persona methods need the `read:personas` / `write:personas` API-key scopes; deployment launch/control needs `write:deployments`; agent data reads need `read:events`.
@@ -189,8 +203,8 @@ Persona methods need the `read:personas` / `write:personas` API-key scopes; depl
189
203
  ```python
190
204
  data = hs.get_all_data("participant_id", room_id="room_id")
191
205
  # Returns dict with keys: events, recordings, chat, videochat, sync,
192
- # ratings_continuous, ratings_sparse, components, questionnaire,
193
- # instructions, consent, agent_decisions
206
+ # ratings_continuous, ratings_sparse, components, eyetracking,
207
+ # questionnaire, instructions, consent, agent_decisions
194
208
  ```
195
209
 
196
210
  ## API Key
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperstudy"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Python SDK for the HyperStudy experiment platform API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -56,7 +56,7 @@ from .models import (
56
56
  waiting,
57
57
  )
58
58
 
59
- __version__ = "0.4.0"
59
+ __version__ = "0.5.0"
60
60
 
61
61
  __all__ = [
62
62
  "HyperStudy",
@@ -20,6 +20,7 @@ class DataType(str, Enum):
20
20
  VIDEOCHAT = "videochat"
21
21
  SYNC = "sync"
22
22
  RATINGS = "ratings"
23
+ EYETRACKING = "eyetracking"
23
24
  COMPONENTS = "components"
24
25
  PARTICIPANTS = "participants"
25
26
  ROOMS = "rooms"
@@ -235,6 +235,35 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
235
235
  output=output, progress=progress,
236
236
  )
237
237
 
238
+ def get_eyetracking(
239
+ self,
240
+ scope_id: str,
241
+ *,
242
+ scope: str = "experiment",
243
+ room_id: str | None = None,
244
+ limit: int | None = None,
245
+ offset: int = 0,
246
+ output: str = "pandas",
247
+ progress: bool = True,
248
+ ):
249
+ """Fetch eye-tracking gaze data.
250
+
251
+ Args:
252
+ scope_id: ID of the experiment, room, or participant.
253
+ scope: ``"experiment"``, ``"room"``, or ``"participant"``.
254
+ room_id: Required when ``scope="participant"``.
255
+ limit: Max records. ``None`` fetches all pages.
256
+ offset: Starting offset.
257
+ output: ``"pandas"`` (default), ``"polars"``, or ``"dict"``.
258
+ progress: Show progress bar when paginating.
259
+ """
260
+ return self._fetch_data(
261
+ "eyetracking", scope_id,
262
+ scope=scope, room_id=room_id,
263
+ limit=limit, offset=offset,
264
+ output=output, progress=progress,
265
+ )
266
+
238
267
  def get_components(
239
268
  self,
240
269
  scope_id: str,
@@ -294,6 +323,67 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
294
323
  output=output, progress=progress,
295
324
  )
296
325
 
326
+ def get_variables(
327
+ self,
328
+ room_id: str,
329
+ *,
330
+ output: str = "pandas",
331
+ ):
332
+ """Fetch the reconstructed shared-variable timeline for a room.
333
+
334
+ Returns a dict with four keys:
335
+
336
+ - ``writes`` — the time-ordered write log (one row per variable
337
+ write: seeded constant, human component response, or agent
338
+ submit-response, tagged with source and persisted flag), converted
339
+ per ``output``
340
+ - ``timeline`` — the derived per-state forward-filled variable
341
+ snapshot matrix, converted per ``output``
342
+ - ``variable_names`` — ordered list of every variable seen
343
+ - ``matrix_columns`` — ordered column set of the timeline matrix
344
+ - ``dropped_writes`` — writes that failed the server's ground-truth
345
+ cross-checks (a bug signal; empty is the healthy state)
346
+ - ``mode`` — the reconstruction mode the server processor ran in
347
+
348
+ Args:
349
+ room_id: Room ID.
350
+ output: ``"pandas"`` (default), ``"polars"``, or ``"dict"``
351
+ for the tabular values.
352
+ """
353
+ body = self._transport.get(f"data/variables/room/{room_id}")
354
+ metadata = body.get("metadata") or {}
355
+ return {
356
+ "writes": self._convert_output(body.get("data", []), output),
357
+ "timeline": self._convert_output(metadata.get("timeline") or [], output),
358
+ "variable_names": metadata.get("variableNames") or [],
359
+ "matrix_columns": metadata.get("matrixColumns") or [],
360
+ "dropped_writes": metadata.get("droppedWrites") or [],
361
+ "mode": metadata.get("mode"),
362
+ }
363
+
364
+ def get_counts(
365
+ self,
366
+ participant_id: str,
367
+ room_id: str,
368
+ ) -> dict[str, Any]:
369
+ """Fetch per-data-type document counts for a participant in a room.
370
+
371
+ Cheap count queries grouped by data type — useful for checking
372
+ which data types exist before fetching them.
373
+
374
+ Args:
375
+ participant_id: Participant ID.
376
+ room_id: Room ID.
377
+
378
+ Returns:
379
+ Dict with ``counts`` and ``hasData`` keyed by data type.
380
+ """
381
+ body = self._transport.get(
382
+ f"data/counts/participant/{participant_id}", params={"roomId": room_id}
383
+ )
384
+ data = body.get("data", [])
385
+ return data[0] if isinstance(data, list) and data else data
386
+
297
387
  # ------------------------------------------------------------------
298
388
  # Convenience: category-filtered events
299
389
  # ------------------------------------------------------------------
@@ -639,6 +729,7 @@ class HyperStudy(ExperimentMixin, PersonaMixin):
639
729
  "ratings_continuous": self.get_ratings(participant_id, kind="continuous", **common),
640
730
  "ratings_sparse": self.get_ratings(participant_id, kind="sparse", **common),
641
731
  "components": self.get_components(participant_id, **common),
732
+ "eyetracking": self.get_eyetracking(participant_id, **common),
642
733
  "questionnaire": self.get_questionnaire(participant_id, **common),
643
734
  "instructions": self.get_instructions(participant_id, **common),
644
735
  "consent": self.get_consent(participant_id, **common),
@@ -115,6 +115,17 @@ class ExperimentMixin:
115
115
  data = body.get("data", [])
116
116
  return data[0] if isinstance(data, list) and data else data
117
117
 
118
+ def export_experiment(self, experiment_id: str) -> dict[str, Any]:
119
+ """Export an experiment as portable JSON.
120
+
121
+ Returns:
122
+ The export payload (experiment definition plus media-asset
123
+ manifest), suitable for re-import.
124
+ """
125
+ body = self._transport.get(f"experiments/{experiment_id}/export")
126
+ data = body.get("data", [])
127
+ return data[0] if isinstance(data, list) and data else data
128
+
118
129
  # ------------------------------------------------------------------
119
130
  # Write
120
131
  # ------------------------------------------------------------------
@@ -186,8 +186,9 @@ class Persona(_Model):
186
186
  """A reusable AI-agent definition (agent library entry).
187
187
 
188
188
  The persona owns the agent's identity, model settings, guardrails and
189
- (optionally) cognition. ``cognition`` / ``offline_cognition`` are accepted
190
- as plain dicts — their schema is experimental and still evolving.
189
+ (optionally) cognition. ``cognition`` is accepted as a plain dict — its
190
+ schema is experimental and still evolving. Offline loops live inside
191
+ ``cognition["config"]["contexts"][<name>]["offline"]``.
191
192
  """
192
193
 
193
194
  name: str = Field(min_length=1)
@@ -204,7 +205,6 @@ class Persona(_Model):
204
205
  disable_thinking: Optional[bool] = None
205
206
  seed: Optional[int] = None
206
207
  cognition: Optional[dict[str, Any]] = None
207
- offline_cognition: Optional[dict[str, Any]] = None
208
208
  memory_persistence: Optional[str] = None # "none" (default) | "cross-experiment"
209
209
  language: Optional[str] = None
210
210
  seed_memories: Optional[list[dict[str, Any]]] = None
@@ -25,6 +25,11 @@ def _persona_payload(persona: "Persona | None", kwargs: dict[str, Any]) -> dict[
25
25
  payload.update(persona.model_dump(by_alias=True, exclude_none=True))
26
26
  if kwargs:
27
27
  payload.update(camelize_wire(kwargs))
28
+ # The server hard-discards offlineCognition (offline loops now live inside
29
+ # cognition.config.contexts.<name>.offline), so never send it — including
30
+ # when a Persona was rebuilt from an older API response via extra fields.
31
+ payload.pop("offlineCognition", None)
32
+ payload.pop("offline_cognition", None)
28
33
  return payload
29
34
 
30
35
 
@@ -101,3 +106,16 @@ class PersonaMixin:
101
106
  """
102
107
  body = self._transport.post(f"personas/{persona_id}/duplicate")
103
108
  return body.get("data", {})
109
+
110
+ def get_cognition_catalog(self) -> dict[str, Any]:
111
+ """Fetch the cognition authoring catalog.
112
+
113
+ Lists the valid building blocks for persona ``cognition`` configs.
114
+ Requires the ``read:personas`` scope.
115
+
116
+ Returns:
117
+ Dict with ``abilities``, ``recipes``, and ``offlineRecipes``.
118
+ """
119
+ body = self._transport.get("agent-cognition/catalog")
120
+ data = body.get("data", [])
121
+ return data[0] if isinstance(data, list) and data else data
@@ -242,6 +242,126 @@ def test_get_ratings_sparse_flattens_data(api_key, sparse_ratings_response):
242
242
  assert df["sparseRatingData_mediaPauseOnset"].iloc[0] == 8200
243
243
 
244
244
 
245
+ @responses.activate
246
+ def test_get_eyetracking(api_key, events_response):
247
+ """get_eyetracking hits the correct endpoint."""
248
+ responses.get(
249
+ f"{BASE_URL}/data/eyetracking/experiment/exp_abc123",
250
+ json=events_response,
251
+ status=200,
252
+ )
253
+ client = HyperStudy(api_key=api_key, base_url=BASE_URL)
254
+ df = client.get_eyetracking("exp_abc123", limit=1000)
255
+ assert isinstance(df, pd.DataFrame)
256
+ assert len(df) == 3
257
+
258
+
259
+ @responses.activate
260
+ def test_get_eyetracking_participant_scope(api_key, events_response):
261
+ """get_eyetracking with scope='participant' passes roomId query param."""
262
+ responses.get(
263
+ f"{BASE_URL}/data/eyetracking/participant/user_1",
264
+ json=events_response,
265
+ status=200,
266
+ )
267
+ client = HyperStudy(api_key=api_key, base_url=BASE_URL)
268
+ df = client.get_eyetracking(
269
+ "user_1", scope="participant", room_id="room_xyz", limit=1000
270
+ )
271
+ assert len(df) == 3
272
+ assert "roomId=room_xyz" in responses.calls[0].request.url
273
+
274
+
275
+ @responses.activate
276
+ def test_get_variables(api_key):
277
+ """get_variables returns writes, timeline, variable names, and dropped writes."""
278
+ responses.get(
279
+ f"{BASE_URL}/data/variables/room/room_xyz",
280
+ json={
281
+ "status": "success",
282
+ "data": [
283
+ {"variable": "rating", "value": 7, "source": "human", "persisted": True},
284
+ {"variable": "condition", "value": "A", "source": "seed", "persisted": True},
285
+ ],
286
+ "metadata": {
287
+ "variableNames": ["condition", "rating"],
288
+ "timeline": [{"stateId": "s1", "condition": "A", "rating": 7}],
289
+ "matrixColumns": ["stateId", "condition", "rating"],
290
+ "droppedWrites": [{"variable": "rating", "reason": "mismatch"}],
291
+ "mode": "replay",
292
+ },
293
+ },
294
+ status=200,
295
+ )
296
+ client = HyperStudy(api_key=api_key, base_url=BASE_URL)
297
+ result = client.get_variables("room_xyz")
298
+
299
+ assert isinstance(result["writes"], pd.DataFrame)
300
+ assert len(result["writes"]) == 2
301
+ assert set(result["writes"]["variable"]) == {"rating", "condition"}
302
+ assert isinstance(result["timeline"], pd.DataFrame)
303
+ assert len(result["timeline"]) == 1
304
+ assert result["variable_names"] == ["condition", "rating"]
305
+ assert result["matrix_columns"] == ["stateId", "condition", "rating"]
306
+ assert result["dropped_writes"] == [{"variable": "rating", "reason": "mismatch"}]
307
+ assert result["mode"] == "replay"
308
+
309
+
310
+ @responses.activate
311
+ def test_get_variables_dict(api_key):
312
+ """get_variables with output='dict' returns raw write and timeline lists."""
313
+ responses.get(
314
+ f"{BASE_URL}/data/variables/room/room_xyz",
315
+ json={
316
+ "status": "success",
317
+ "data": [{"variable": "rating", "value": 7}],
318
+ "metadata": {},
319
+ },
320
+ status=200,
321
+ )
322
+ client = HyperStudy(api_key=api_key, base_url=BASE_URL)
323
+ result = client.get_variables("room_xyz", output="dict")
324
+ assert result["writes"] == [{"variable": "rating", "value": 7}]
325
+ assert result["timeline"] == []
326
+ assert result["variable_names"] == []
327
+ assert result["dropped_writes"] == []
328
+
329
+
330
+ @responses.activate
331
+ def test_get_counts(api_key):
332
+ """get_counts passes roomId and unwraps the counts dict."""
333
+ responses.get(
334
+ f"{BASE_URL}/data/counts/participant/user_1",
335
+ json={
336
+ "status": "success",
337
+ "data": [
338
+ {
339
+ "participantId": "user_1",
340
+ "roomId": "room_xyz",
341
+ "counts": {"textchat": 4, "sync": 0},
342
+ "hasData": {"textchat": True, "sync": False},
343
+ }
344
+ ],
345
+ "metadata": {},
346
+ },
347
+ status=200,
348
+ )
349
+ client = HyperStudy(api_key=api_key, base_url=BASE_URL)
350
+ result = client.get_counts("user_1", room_id="room_xyz")
351
+
352
+ assert isinstance(result, dict)
353
+ assert result["counts"]["textchat"] == 4
354
+ assert result["hasData"]["sync"] is False
355
+ assert "roomId=room_xyz" in responses.calls[0].request.url
356
+
357
+
358
+ def test_get_counts_requires_room_id(api_key):
359
+ """get_counts requires room_id (the server 400s without it)."""
360
+ client = HyperStudy(api_key=api_key, base_url=BASE_URL)
361
+ with pytest.raises(TypeError):
362
+ client.get_counts("user_1")
363
+
364
+
245
365
  @responses.activate
246
366
  def test_get_sync_with_aggregation(api_key, events_response):
247
367
  """get_sync passes aggregationWindow param."""
@@ -519,7 +639,8 @@ def test_get_all_data(api_key, events_response, pre_experiment_response):
519
639
  """get_all_data returns a dict of DataFrames."""
520
640
  # Mock all data type endpoints for participant scope
521
641
  for dtype in ("events", "recordings", "chat", "videochat", "sync",
522
- "ratings/continuous", "ratings/sparse", "components"):
642
+ "ratings/continuous", "ratings/sparse", "components",
643
+ "eyetracking"):
523
644
  responses.get(
524
645
  f"{BASE_URL}/data/{dtype}/participant/user_1",
525
646
  json=events_response,
@@ -541,7 +662,7 @@ def test_get_all_data(api_key, events_response, pre_experiment_response):
541
662
  assert isinstance(result, dict)
542
663
  assert set(result.keys()) == {
543
664
  "events", "recordings", "chat", "videochat", "sync",
544
- "ratings_continuous", "ratings_sparse", "components",
665
+ "ratings_continuous", "ratings_sparse", "components", "eyetracking",
545
666
  "questionnaire", "instructions", "consent", "agent_decisions",
546
667
  }
547
668
  for v in result.values():
@@ -99,6 +99,38 @@ def test_get_experiment_config(experiment_single_response):
99
99
  assert config["id"] == "exp_abc123"
100
100
 
101
101
 
102
+ @responses.activate
103
+ def test_export_experiment():
104
+ """export_experiment returns the portable experiment JSON."""
105
+ export_response = {
106
+ "status": "success",
107
+ "metadata": {
108
+ "dataType": "experimentExport",
109
+ "scope": "experiment",
110
+ "scopeId": "exp_abc123",
111
+ "filename": "emotion-study-export.json",
112
+ "mediaAssetsCount": 2,
113
+ },
114
+ "data": [
115
+ {
116
+ "experiment": {"name": "Emotion Study", "states": []},
117
+ "mediaAssets": [{"url": "https://cdn.example.com/clip.mp4"}],
118
+ }
119
+ ],
120
+ }
121
+ responses.get(
122
+ f"{BASE_URL}/experiments/exp_abc123/export",
123
+ json=export_response,
124
+ status=200,
125
+ )
126
+ client = HyperStudy(api_key="hst_test_key", base_url=BASE_URL)
127
+ export = client.export_experiment("exp_abc123")
128
+
129
+ assert isinstance(export, dict)
130
+ assert export["experiment"]["name"] == "Emotion Study"
131
+ assert len(export["mediaAssets"]) == 1
132
+
133
+
102
134
  @responses.activate
103
135
  def test_create_experiment():
104
136
  """create_experiment POSTs to the experiments endpoint."""
@@ -112,6 +112,76 @@ def test_update_persona_requires_fields(api_key):
112
112
  client.update_persona("persona-1")
113
113
 
114
114
 
115
+ @responses.activate
116
+ def test_create_persona_strips_offline_cognition(api_key):
117
+ """offlineCognition is never sent — the server hard-discards it.
118
+
119
+ Covers both a Persona rebuilt from an older API response (extra field)
120
+ and the snake_case kwargs path. Offline loops now live inside
121
+ cognition.config.contexts.<name>.offline."""
122
+ responses.post(f"{BASE_URL}/personas", json=_envelope(PERSONA_DOC), status=201)
123
+ responses.post(f"{BASE_URL}/personas", json=_envelope(PERSONA_DOC), status=201)
124
+
125
+ client = HyperStudy(api_key=api_key)
126
+
127
+ # Persona rebuilt from an older response doc carrying offlineCognition.
128
+ persona = Persona(**{
129
+ "name": "Rebuilt",
130
+ "cognition": {"config": {"contexts": {"social": {"offline": {"cadence": "endOfState"}}}}},
131
+ "offlineCognition": {"loops": []},
132
+ })
133
+ client.create_persona(persona=persona)
134
+ sent = json.loads(responses.calls[0].request.body)
135
+ assert "offlineCognition" not in sent
136
+ assert "offline_cognition" not in sent
137
+ assert sent["cognition"]["config"]["contexts"]["social"]["offline"] == {
138
+ "cadence": "endOfState"
139
+ }
140
+
141
+ # Snake_case kwargs path.
142
+ client.create_persona(name="Kwargs", offline_cognition={"loops": []})
143
+ sent = json.loads(responses.calls[1].request.body)
144
+ assert "offlineCognition" not in sent
145
+ assert "offline_cognition" not in sent
146
+
147
+
148
+ @responses.activate
149
+ def test_update_persona_strips_offline_cognition(api_key):
150
+ """update_persona also drops offlineCognition from the payload."""
151
+ responses.put(f"{BASE_URL}/personas/persona-1", json=_envelope(PERSONA_DOC), status=200)
152
+
153
+ client = HyperStudy(api_key=api_key)
154
+ client.update_persona("persona-1", description="Updated", offline_cognition={"loops": []})
155
+
156
+ sent = json.loads(responses.calls[0].request.body)
157
+ assert sent == {"description": "Updated"}
158
+
159
+
160
+ def test_persona_model_has_no_offline_cognition_field():
161
+ """The typed Persona model no longer declares offline_cognition."""
162
+ assert "offline_cognition" not in Persona.model_fields
163
+
164
+
165
+ @responses.activate
166
+ def test_get_cognition_catalog(api_key):
167
+ """get_cognition_catalog returns the abilities/recipes/offlineRecipes dict."""
168
+ catalog = {
169
+ "abilities": {"perceive": {"description": "..."}},
170
+ "recipes": {"default": ["perceive"]},
171
+ "offlineRecipes": {"consolidate": ["reflect"]},
172
+ }
173
+ # The v3 envelope array-wraps non-array data; the client unwraps it.
174
+ responses.get(
175
+ f"{BASE_URL}/agent-cognition/catalog", json=_envelope([catalog]), status=200
176
+ )
177
+
178
+ client = HyperStudy(api_key=api_key)
179
+ result = client.get_cognition_catalog()
180
+
181
+ assert result == catalog
182
+ assert set(result.keys()) == {"abilities", "recipes", "offlineRecipes"}
183
+
184
+
115
185
  @responses.activate
116
186
  def test_delete_and_duplicate_persona(api_key):
117
187
  responses.delete(f"{BASE_URL}/personas/persona-1", json=_envelope({"deleted": True}), status=200)
File without changes
File without changes
File without changes
File without changes