methodic-research 0.35.0__tar.gz → 0.37.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 (58) hide show
  1. {methodic_research-0.35.0/src/methodic_research.egg-info → methodic_research-0.37.0}/PKG-INFO +1 -1
  2. {methodic_research-0.35.0 → methodic_research-0.37.0}/pyproject.toml +1 -1
  3. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/__init__.py +8 -0
  4. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/chronicle.py +4 -0
  5. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/experiments.py +65 -3
  6. methodic_research-0.37.0/src/methodic/feed.py +170 -0
  7. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/types.py +113 -3
  8. {methodic_research-0.35.0 → methodic_research-0.37.0/src/methodic_research.egg-info}/PKG-INFO +1 -1
  9. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/SOURCES.txt +2 -0
  10. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_experiments.py +96 -0
  11. methodic_research-0.37.0/tests/test_feed.py +135 -0
  12. {methodic_research-0.35.0 → methodic_research-0.37.0}/LICENSE +0 -0
  13. {methodic_research-0.35.0 → methodic_research-0.37.0}/README.md +0 -0
  14. {methodic_research-0.35.0 → methodic_research-0.37.0}/setup.cfg +0 -0
  15. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/activity.py +0 -0
  16. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/api_keys.py +0 -0
  17. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/assets.py +0 -0
  18. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/cli.py +0 -0
  19. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/collections.py +0 -0
  20. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/datasets.py +0 -0
  21. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/errata.py +0 -0
  22. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/errors.py +0 -0
  23. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/feedback.py +0 -0
  24. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/imports.py +0 -0
  25. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/me.py +0 -0
  26. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/pending.py +0 -0
  27. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/publications.py +0 -0
  28. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/reports.py +0 -0
  29. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/research_prompts.py +0 -0
  30. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/runs.py +0 -0
  31. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/search.py +0 -0
  32. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/tags.py +0 -0
  33. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/transport.py +0 -0
  34. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/upload_tracker.py +0 -0
  35. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/variations.py +0 -0
  36. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/dependency_links.txt +0 -0
  37. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/entry_points.txt +0 -0
  38. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/requires.txt +0 -0
  39. {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/top_level.txt +0 -0
  40. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_activity.py +0 -0
  41. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_api_keys.py +0 -0
  42. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_assets.py +0 -0
  43. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_cli.py +0 -0
  44. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_client.py +0 -0
  45. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_collections.py +0 -0
  46. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_config.py +0 -0
  47. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_datasets.py +0 -0
  48. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_distill.py +0 -0
  49. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_feedback.py +0 -0
  50. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_imports.py +0 -0
  51. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_pending.py +0 -0
  52. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_pending_reasons.py +0 -0
  53. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_reports.py +0 -0
  54. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_research_prompts.py +0 -0
  55. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_runs.py +0 -0
  56. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_search.py +0 -0
  57. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_upload_tracker.py +0 -0
  58. {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_variations.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodic-research
3
- Version: 0.35.0
3
+ Version: 0.37.0
4
4
  Summary: Python client for the Chronicle experiment platform
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Documentation, https://docs.methodiclabs.ai
@@ -8,7 +8,7 @@ name = "methodic-research"
8
8
  # methodic-lib-publish.yml overwrites this from versions.toml at build
9
9
  # time, so the published artifact has a single source of truth. Kept
10
10
  # here for local `pip install -e conductor/`.
11
- version = "0.35.0"
11
+ version = "0.37.0"
12
12
  description = "Python client for the Chronicle experiment platform"
13
13
  requires-python = ">=3.11"
14
14
  license = "Apache-2.0"
@@ -18,6 +18,7 @@ from methodic.errors import (
18
18
  ServerError,
19
19
  )
20
20
  from methodic.experiments import Experiment, ExperimentsAPI
21
+ from methodic.feed import FeedAPI
21
22
  from methodic.feedback import FeedbackAPI
22
23
  from methodic.pending import PendingAPI
23
24
  from methodic.publications import PublicationsAPI
@@ -37,6 +38,8 @@ from methodic.types import (
37
38
  ExperimentDetail,
38
39
  ExperimentListPage,
39
40
  ExperimentSummary,
41
+ FeedEvent,
42
+ FeedPage,
40
43
  GitBranch,
41
44
  GitStatus,
42
45
  GitToken,
@@ -47,6 +50,7 @@ from methodic.types import (
47
50
  SearchResponse,
48
51
  SearchResult,
49
52
  SearchScope,
53
+ TentativeParentLink,
50
54
  UpstreamRetraction,
51
55
  UpstreamRetractionsResponse,
52
56
  Variation as VariationData,
@@ -81,6 +85,9 @@ __all__ = [
81
85
  "ExperimentListPage",
82
86
  "ExperimentSummary",
83
87
  "ExperimentsAPI",
88
+ "FeedAPI",
89
+ "FeedEvent",
90
+ "FeedPage",
84
91
  "FeedbackAPI",
85
92
  "GitBranch",
86
93
  "GitStatus",
@@ -106,6 +113,7 @@ __all__ = [
106
113
  "PublicationsAPI",
107
114
  "ServerError",
108
115
  "TagsAPI",
116
+ "TentativeParentLink",
109
117
  "UploadTracker",
110
118
  "UpstreamRetraction",
111
119
  "UpstreamRetractionsResponse",
@@ -25,6 +25,7 @@ from methodic.datasets import DatasetsAPI
25
25
  from methodic.errata import ErrataAPI
26
26
  from methodic.errors import ChronicleConfigError
27
27
  from methodic.experiments import Experiment, ExperimentsAPI
28
+ from methodic.feed import FeedAPI
28
29
  from methodic.feedback import FeedbackAPI
29
30
  from methodic.imports import ImportsAPI
30
31
  from methodic.me import MeAPI
@@ -274,6 +275,9 @@ class Chronicle:
274
275
  # Attention feed — the caller's actionable queue + event stream.
275
276
  self.pending = PendingAPI(self._transport, self)
276
277
  self.activity = ActivityAPI(self._transport, self)
278
+ # Unified event feed (event-stream.md §6): activity + needs-you + inbox
279
+ # + research, with an explicit per-section pointer (see FeedAPI.seen).
280
+ self.feed = FeedAPI(self._transport, self)
277
281
 
278
282
  @property
279
283
  def organization_id(self) -> str | None:
@@ -17,6 +17,7 @@ from methodic.types import (
17
17
  GitStatus,
18
18
  GitToken,
19
19
  LineageResponse,
20
+ TentativeParentLink,
20
21
  UpstreamRetractionsResponse,
21
22
  resolve_organization_id,
22
23
  )
@@ -162,7 +163,28 @@ class ExperimentsAPI:
162
163
  return
163
164
  token = page.next_page_token
164
165
 
165
- def commit(self, experiment_id: str) -> dict[str, Any]:
166
+ def commit(
167
+ self,
168
+ experiment_id: str,
169
+ *,
170
+ tentative_links: dict[str, str] | None = None,
171
+ ) -> dict[str, Any]:
172
+ """Commit (lock the spec of) an experiment.
173
+
174
+ If the experiment has **tentative** parent lineage links (from being
175
+ created/forked off a still-``open`` parent — see
176
+ :meth:`tentative_links`), every one must be resolved here or commit
177
+ returns 409: pass ``tentative_links`` mapping each parent experiment id
178
+ to ``"promote"`` (make it real lineage — only valid once the parent has
179
+ committed) or ``"drop"`` (discard the link). Naming a parent that isn't
180
+ a tentative link, or omitting one that is, is rejected — there is no
181
+ silent default.
182
+ """
183
+ if tentative_links is not None:
184
+ return self._t.put(
185
+ f"/experiments/{experiment_id}/commit",
186
+ json={"tentative_links": tentative_links},
187
+ )
166
188
  return self._t.put(f"/experiments/{experiment_id}/commit")
167
189
 
168
190
  def conclude(
@@ -399,6 +421,33 @@ class ExperimentsAPI:
399
421
  self._t.get(f"/experiments/{experiment_id}/lineage", params=params or None)
400
422
  )
401
423
 
424
+ def tentative_links(self, experiment_id: str) -> list[TentativeParentLink]:
425
+ """The experiment's **tentative** parent lineage links.
426
+
427
+ A tentative link is created when an experiment is created/forked off a
428
+ parent that is still ``open`` (uncommitted). It is **excluded from
429
+ lineage reads** (won't show in :meth:`get` ``parent_ids`` or
430
+ :meth:`get_lineage`) and blocks the child's commit until resolved.
431
+ Returns ``[]`` for the common case (no tentative links). Resolve each
432
+ by :meth:`promote_lineage` (once ``promotable``) or by passing a
433
+ ``drop``/``promote`` disposition to :meth:`commit`.
434
+ """
435
+ resp = self._t.get(f"/experiments/{experiment_id}/tentative-links")
436
+ return [
437
+ TentativeParentLink.from_dict(t) for t in resp.get("tentative_links", [])
438
+ ]
439
+
440
+ def promote_lineage(self, experiment_id: str, parent_id: str) -> dict[str, Any]:
441
+ """Promote a tentative parent link to real (explicit) lineage.
442
+
443
+ The deliberate "yes, this fork is real lineage" action: the parent then
444
+ appears in :meth:`get` ``parent_ids`` and the lineage DAG. Requires the
445
+ parent to have **committed** (else 409); a tentative link off a still-
446
+ ``open`` parent is not yet promotable. Idempotent — re-promoting an
447
+ already-explicit link succeeds.
448
+ """
449
+ return self._t.post(f"/experiments/{experiment_id}/lineage/{parent_id}/promote")
450
+
402
451
  def git_status(self, experiment_id: str) -> GitStatus:
403
452
  """Current git-integration state for the experiment.
404
453
 
@@ -537,8 +586,11 @@ class Experiment:
537
586
  """Mint a 1-hour install token scoped to this experiment's repo."""
538
587
  return self._chronicle.experiments.mint_git_token(self.id)
539
588
 
540
- def commit(self) -> Experiment:
541
- self._chronicle.experiments.commit(self.id)
589
+ def commit(self, *, tentative_links: dict[str, str] | None = None) -> Experiment:
590
+ """Commit (lock the spec). Pass ``tentative_links`` (parent id
591
+ ``"promote"``/``"drop"``) to resolve any tentative parent links, which
592
+ otherwise block commit. See :meth:`ExperimentsAPI.commit`."""
593
+ self._chronicle.experiments.commit(self.id, tentative_links=tentative_links)
542
594
  self._detail = None
543
595
  return self
544
596
 
@@ -651,6 +703,16 @@ class Experiment:
651
703
  self.id, direction=direction, depth=depth
652
704
  )
653
705
 
706
+ def tentative_links(self) -> list[TentativeParentLink]:
707
+ """This experiment's tentative parent lineage links (empty for the
708
+ common case). See :meth:`ExperimentsAPI.tentative_links`."""
709
+ return self._chronicle.experiments.tentative_links(self.id)
710
+
711
+ def promote_lineage(self, parent_id: str) -> dict[str, Any]:
712
+ """Promote a tentative parent link to real lineage (parent must have
713
+ committed). See :meth:`ExperimentsAPI.promote_lineage`."""
714
+ return self._chronicle.experiments.promote_lineage(self.id, parent_id)
715
+
654
716
  def get_upstream_retractions(
655
717
  self,
656
718
  *,
@@ -0,0 +1,170 @@
1
+ """Feed namespace — the caller's unified event feed.
2
+
3
+ Wraps ``GET /v1/feed`` (the JSON page) and ``POST /v1/feed/seen`` (the
4
+ per-section cursor) — the unified event stream that subsumes activity, the
5
+ actionable "needs you" items (blocked / report_approval / invites), new
6
+ reports, and research recommendations (event-stream.md §6).
7
+
8
+ Two cross-cutting lenses are preset filters, not separate calls:
9
+
10
+ - **needs-you** — ``list(actionable=True, open=True)``: blocked agents +
11
+ approvals + invites still awaiting you.
12
+ - **inbox** — ``list(recipient="me")``: items addressed to you specifically.
13
+
14
+ The list is ACL-bounded server-side (the same enumeration authorization the
15
+ experiment listing uses). Pagination is a keyset on ``seq``: the JSON body is a
16
+ bare array, the server signals more via ``X-Has-More`` and this read's
17
+ unseen/open count via ``X-Unseen-Count``, and the next (older) page is fetched
18
+ with ``before=<seq>``.
19
+
20
+ **Pointer contract (event-stream.md §4).** Reading never advances your pointer.
21
+ After you have *durably processed* the events you read, advance it explicitly
22
+ with :meth:`seen` — at-least-once: a crash between read and ack re-delivers,
23
+ never drops. (The web UI advances automatically on view; an agent must ack
24
+ deliberately.)
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from typing import TYPE_CHECKING, Any, Iterator
31
+
32
+ from methodic.pending import _has_more
33
+ from methodic.transport import Transport
34
+ from methodic.types import FeedPage
35
+
36
+ if TYPE_CHECKING:
37
+ from methodic.chronicle import Chronicle
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ def _unseen_count(headers: Any) -> int:
43
+ """Read the ``X-Unseen-Count`` response header as an int (default 0)."""
44
+ try:
45
+ return int(str(headers.get("X-Unseen-Count", "0")).strip() or 0)
46
+ except (TypeError, ValueError):
47
+ return 0
48
+
49
+
50
+ def _bool_param(value: bool) -> str:
51
+ """Serialize a bool for the query string as serde expects (``true``/``false``)."""
52
+ return "true" if value else "false"
53
+
54
+
55
+ class FeedAPI:
56
+ """The caller's unified event feed (activity + needs-you + inbox + research)."""
57
+
58
+ def __init__(self, transport: Transport, chronicle: Chronicle) -> None:
59
+ self._t = transport
60
+ self._chronicle = chronicle
61
+
62
+ def list(
63
+ self,
64
+ *,
65
+ category: str | list[str] | None = None,
66
+ actionable: bool | None = None,
67
+ open: bool | None = None,
68
+ recipient: str | None = None,
69
+ experiment: str | None = None,
70
+ kind: str | None = None,
71
+ severity: str | None = None,
72
+ after_seq: int | None = None,
73
+ before: int | None = None,
74
+ limit: int | None = None,
75
+ order: str | None = None,
76
+ ) -> FeedPage:
77
+ """One page of feed events, newest-first (or ``score``-first for research).
78
+
79
+ Filters mirror ``GET /v1/feed``: ``category`` (a string or list of
80
+ categories), the ``actionable`` / ``open`` lens, ``recipient="me"`` for
81
+ the inbox, plus ``experiment`` / ``kind`` / ``severity``. Page with
82
+ ``limit`` + ``before=<seq>`` (older), or poll forward with
83
+ ``after_seq=<seq>``. ``order`` is ``"seq"`` (default) or ``"score"``.
84
+
85
+ The result carries ``unseen_count`` (this section's unseen/open badge)
86
+ and a ``next_page_token`` (the last event's ``seq``) when more remain;
87
+ pass it back as ``before``, or use :meth:`iter` to walk every page.
88
+ Reading does **not** advance your pointer — call :meth:`seen` after you
89
+ act.
90
+ """
91
+ params: dict[str, Any] = {}
92
+ if category is not None:
93
+ params["category"] = (
94
+ ",".join(category) if isinstance(category, list) else category
95
+ )
96
+ if actionable is not None:
97
+ params["actionable"] = _bool_param(actionable)
98
+ if open is not None:
99
+ params["open"] = _bool_param(open)
100
+ if recipient is not None:
101
+ params["recipient"] = recipient
102
+ if experiment is not None:
103
+ params["experiment"] = experiment
104
+ if kind is not None:
105
+ params["kind"] = kind
106
+ if severity is not None:
107
+ params["severity"] = severity
108
+ if after_seq is not None:
109
+ params["after_seq"] = after_seq
110
+ if before is not None:
111
+ params["before"] = before
112
+ if limit is not None:
113
+ params["limit"] = limit
114
+ if order is not None:
115
+ params["order"] = order
116
+ rows, headers = self._t.get_with_headers("/feed", params=params or None)
117
+ return FeedPage.from_list(
118
+ rows, has_more=_has_more(headers), unseen_count=_unseen_count(headers)
119
+ )
120
+
121
+ def iter(
122
+ self,
123
+ *,
124
+ category: str | list[str] | None = None,
125
+ actionable: bool | None = None,
126
+ open: bool | None = None,
127
+ recipient: str | None = None,
128
+ experiment: str | None = None,
129
+ kind: str | None = None,
130
+ severity: str | None = None,
131
+ order: str | None = None,
132
+ limit: int | None = None,
133
+ ) -> Iterator[Any]:
134
+ """Yield every matching feed event, paging server-side as needed.
135
+
136
+ Walks oldest-ward via the ``before=<seq>`` cursor. Forward polling
137
+ (``after_seq``) is a single-call concern, so it is intentionally not a
138
+ parameter here — use :meth:`list` for incremental tailing.
139
+ """
140
+ token: int | None = None
141
+ while True:
142
+ page = self.list(
143
+ category=category,
144
+ actionable=actionable,
145
+ open=open,
146
+ recipient=recipient,
147
+ experiment=experiment,
148
+ kind=kind,
149
+ severity=severity,
150
+ before=token,
151
+ limit=limit,
152
+ order=order,
153
+ )
154
+ yield from page.results
155
+ if page.next_page_token is None:
156
+ return
157
+ token = int(page.next_page_token)
158
+
159
+ def seen(self, *, section: str, through_seq: int) -> None:
160
+ """Advance your pointer for one section to a seq you have FINISHED handling.
161
+
162
+ ``section`` is ``blocked`` / ``approvals`` / ``research`` / ``passive``
163
+ (see each event's section); ``through_seq`` is the highest event ``seq``
164
+ you've durably processed. Monotonic — a lower seq is a no-op. Call this
165
+ only *after* acting on the events (at-least-once); reading never advances
166
+ the pointer.
167
+ """
168
+ self._t.post(
169
+ "/feed/seen", json={"section": section, "through_seq": through_seq}
170
+ )
@@ -228,17 +228,55 @@ class UpstreamRetractionsResponse:
228
228
  )
229
229
 
230
230
 
231
+ @dataclass
232
+ class TentativeParentLink:
233
+ """One **tentative** parent lineage link — recorded by creating/forking an
234
+ experiment whose parent was still ``open`` (uncommitted) at the time.
235
+
236
+ A tentative link is **excluded from lineage reads** (it does not appear in
237
+ ``ExperimentDetail.parent_ids`` or ``experiments.get_lineage``) and the
238
+ child **cannot commit** until every one is resolved — promoted to real
239
+ lineage (only once ``promotable``) or dropped. Surfaced by
240
+ ``CreateExperimentResponse.tentative_parents`` and
241
+ ``experiments.tentative_links(id)``.
242
+ """
243
+
244
+ parent_id: str
245
+ parent_state: str
246
+ promotable: bool
247
+
248
+ @classmethod
249
+ def from_dict(cls, data: dict[str, Any]) -> TentativeParentLink:
250
+ return _from_dict(cls, data)
251
+
252
+
231
253
  @dataclass
232
254
  class CreateExperimentResponse:
233
- """`POST /experiments` response: the new experiment plus the always-created variation 0 / run 0."""
255
+ """`POST /experiments` response: the new experiment plus the always-created variation 0 / run 0.
256
+
257
+ ``tentative_parents`` is non-empty only when the experiment was created
258
+ with a parent that was still ``open`` — see :class:`TentativeParentLink`.
259
+ Empty for the common (no-parent / committed-parent) case.
260
+ """
234
261
 
235
262
  experiment_id: str
236
263
  variation: int
237
264
  run: int
265
+ tentative_parents: list[TentativeParentLink] = dataclasses.field(
266
+ default_factory=list
267
+ )
238
268
 
239
269
  @classmethod
240
270
  def from_dict(cls, data: dict[str, Any]) -> CreateExperimentResponse:
241
- return _from_dict(cls, data)
271
+ return cls(
272
+ experiment_id=data["experiment_id"],
273
+ variation=data["variation"],
274
+ run=data["run"],
275
+ tentative_parents=[
276
+ TentativeParentLink.from_dict(t)
277
+ for t in data.get("tentative_parents", [])
278
+ ],
279
+ )
242
280
 
243
281
 
244
282
  @dataclass
@@ -425,7 +463,7 @@ class ExperimentListPage:
425
463
  )
426
464
 
427
465
 
428
- # ── Attention feed (pending + activity) ───────────────────────────────────
466
+ # ── Attention feed (pending + activity + unified feed) ─────────────────────
429
467
 
430
468
 
431
469
  @dataclass
@@ -545,6 +583,78 @@ class ActivityPage:
545
583
  return cls(results=results, next_page_token=next_token)
546
584
 
547
585
 
586
+ @dataclass
587
+ class FeedEvent:
588
+ """One row of the unified event feed from `GET /v1/feed` (event-stream.md).
589
+
590
+ The feed unifies activity, the actionable "needs you" items (``blocked`` /
591
+ ``report_approval`` / invites), new reports, and research recommendations
592
+ under one stream. `seq` is the global monotonic cursor key — the value you
593
+ pass to :meth:`FeedAPI.seen` (as ``through_seq``) and to ``before`` /
594
+ ``after_seq`` for paging. `category` is the granular content type and rolls
595
+ up into a `section` (``blocked`` / ``approvals`` / ``research`` /
596
+ ``passive``) — the unit the cursor is keyed on. `actionable` items stay open
597
+ until `resolved_at` is set. `recipient_principal_id` is set when the event is
598
+ addressed to a specific principal (the inbox lens); broadcast/resource items
599
+ use `audience` + `experiment_id` instead. `payload` is the event-specific
600
+ "what changed" blob.
601
+ """
602
+
603
+ seq: int
604
+ id: str
605
+ category: str
606
+ kind: str
607
+ created_at: str
608
+ via: str
609
+ audience: str
610
+ actionable: bool
611
+ severity: str
612
+ payload: dict[str, Any]
613
+ actor_subject: str | None = None
614
+ resource_type: str | None = None
615
+ resource_id: str | None = None
616
+ experiment_id: str | None = None
617
+ recipient_principal_id: str | None = None
618
+ resolved_at: str | None = None
619
+ resolved_by: str | None = None
620
+ resolving_seq: int | None = None
621
+ score: float | None = None
622
+
623
+ @classmethod
624
+ def from_dict(cls, data: dict[str, Any]) -> FeedEvent:
625
+ return _from_dict(cls, data)
626
+
627
+
628
+ @dataclass
629
+ class FeedPage:
630
+ """One page of `feed.list` results plus the section unseen count + a cursor.
631
+
632
+ The server returns a **bare array** and carries page metadata in headers:
633
+ ``X-Has-More`` (it over-fetches one row to decide) and ``X-Unseen-Count``
634
+ (this read's unseen/open count for the queried section). The SDK reads both;
635
+ when more remain it builds ``next_page_token`` from the last event's ``seq``
636
+ — the ``?before`` cursor the server consumes for the next (older) page.
637
+ ``next_page_token`` is ``None`` on the last page. Use :meth:`FeedAPI.iter` to
638
+ walk every page.
639
+ """
640
+
641
+ results: list[FeedEvent]
642
+ unseen_count: int = 0
643
+ next_page_token: str | None = None
644
+
645
+ @classmethod
646
+ def from_list(
647
+ cls, rows: Any, *, has_more: bool, unseen_count: int = 0
648
+ ) -> FeedPage:
649
+ results = [FeedEvent.from_dict(r) for r in (rows or [])]
650
+ next_token = None
651
+ if has_more and results:
652
+ next_token = str(results[-1].seq)
653
+ return cls(
654
+ results=results, unseen_count=unseen_count, next_page_token=next_token
655
+ )
656
+
657
+
548
658
  @dataclass
549
659
  class BulkApproveResult:
550
660
  """Per-asset outcome within a `BulkApproveResponse`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodic-research
3
- Version: 0.35.0
3
+ Version: 0.37.0
4
4
  Summary: Python client for the Chronicle experiment platform
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Documentation, https://docs.methodiclabs.ai
@@ -12,6 +12,7 @@ src/methodic/datasets.py
12
12
  src/methodic/errata.py
13
13
  src/methodic/errors.py
14
14
  src/methodic/experiments.py
15
+ src/methodic/feed.py
15
16
  src/methodic/feedback.py
16
17
  src/methodic/imports.py
17
18
  src/methodic/me.py
@@ -42,6 +43,7 @@ tests/test_config.py
42
43
  tests/test_datasets.py
43
44
  tests/test_distill.py
44
45
  tests/test_experiments.py
46
+ tests/test_feed.py
45
47
  tests/test_feedback.py
46
48
  tests/test_imports.py
47
49
  tests/test_pending.py
@@ -390,6 +390,102 @@ def test_upstream_retractions_full_chain(requests_mock):
390
390
  assert resp.retractions == []
391
391
 
392
392
 
393
+ # --- Tentative lineage links (fork of an open parent) ---
394
+
395
+ def test_create_surfaces_tentative_parents(requests_mock):
396
+ requests_mock.post(
397
+ f"{BASE}/v1/experiments",
398
+ json={
399
+ "experiment_id": EXP,
400
+ "variation": 0,
401
+ "run": 0,
402
+ "tentative_parents": [
403
+ {"parent_id": "p-open", "parent_state": "open", "promotable": False}
404
+ ],
405
+ },
406
+ )
407
+
408
+ exp = _chronicle().experiments.create(
409
+ hypothesis_summary="builds on p-open",
410
+ config_yaml="model: {}\n",
411
+ parent_experiment_ids=["p-open"],
412
+ )
413
+
414
+ links = exp._create_response.tentative_parents
415
+ assert len(links) == 1
416
+ assert links[0].parent_id == "p-open"
417
+ assert links[0].parent_state == "open"
418
+ assert links[0].promotable is False
419
+
420
+
421
+ def test_tentative_links_lists_pending_links(requests_mock):
422
+ requests_mock.get(
423
+ f"{BASE}/v1/experiments/{EXP}/tentative-links",
424
+ json={
425
+ "experiment_id": EXP,
426
+ "tentative_links": [
427
+ {"parent_id": "p1", "parent_state": "committed", "promotable": True}
428
+ ],
429
+ },
430
+ )
431
+
432
+ links = _chronicle().experiments.tentative_links(EXP)
433
+
434
+ assert requests_mock.last_request.path == f"/v1/experiments/{EXP}/tentative-links"
435
+ assert len(links) == 1
436
+ assert links[0].parent_id == "p1"
437
+ assert links[0].promotable is True
438
+
439
+
440
+ def test_promote_lineage_posts_to_promote_route(requests_mock):
441
+ requests_mock.post(
442
+ f"{BASE}/v1/experiments/{EXP}/lineage/p1/promote",
443
+ json={"experiment_id": EXP, "parent_id": "p1", "promoted": True},
444
+ )
445
+
446
+ resp = _chronicle().experiments.promote_lineage(EXP, "p1")
447
+
448
+ assert requests_mock.last_request.path == f"/v1/experiments/{EXP}/lineage/p1/promote"
449
+ assert resp["promoted"] is True
450
+
451
+
452
+ def test_commit_sends_tentative_link_resolutions(requests_mock):
453
+ requests_mock.put(f"{BASE}/v1/experiments/{EXP}/commit", json={"committed": True})
454
+
455
+ _chronicle().experiments.commit(
456
+ EXP, tentative_links={"p1": "promote", "p2": "drop"}
457
+ )
458
+
459
+ body = requests_mock.last_request.json()
460
+ assert body["tentative_links"] == {"p1": "promote", "p2": "drop"}
461
+
462
+
463
+ def test_commit_without_resolutions_sends_no_body(requests_mock):
464
+ requests_mock.put(f"{BASE}/v1/experiments/{EXP}/commit", json={"committed": True})
465
+
466
+ _chronicle().experiments.commit(EXP)
467
+
468
+ assert requests_mock.last_request.body is None
469
+
470
+
471
+ def test_handle_promote_lineage_proxies(requests_mock):
472
+ requests_mock.get(
473
+ f"{BASE}/v1/experiments/{EXP}",
474
+ json=_detail_payload(),
475
+ )
476
+ requests_mock.post(
477
+ f"{BASE}/v1/experiments/{EXP}/lineage/p1/promote",
478
+ json={"experiment_id": EXP, "parent_id": "p1", "promoted": True},
479
+ )
480
+
481
+ exp = _chronicle().experiments.get(EXP)
482
+ from methodic import Experiment
483
+
484
+ handle = Experiment(_chronicle(), EXP, _detail=exp)
485
+ resp = handle.promote_lineage("p1")
486
+ assert resp["promoted"] is True
487
+
488
+
393
489
  # --- Variations sub-namespace via handle ---
394
490
 
395
491
  def test_variations_create_through_handle(requests_mock):
@@ -0,0 +1,135 @@
1
+ """Tests for the Feed namespace (the caller's unified event feed)."""
2
+
3
+ from methodic import Chronicle
4
+
5
+ BASE = "http://localhost:8080"
6
+
7
+
8
+ def _chronicle():
9
+ return Chronicle(server_url=BASE, api_key="sk_user_test")
10
+
11
+
12
+ def _event(**overrides):
13
+ base = {
14
+ "seq": 42,
15
+ "id": "11111111-1111-1111-1111-111111111111",
16
+ "category": "activity",
17
+ "kind": "run.succeeded",
18
+ "created_at": "2026-06-02T12:00:00Z",
19
+ "via": "direct",
20
+ "audience": "experiment_readers",
21
+ "actionable": False,
22
+ "severity": "info",
23
+ "payload": {"foo": "bar"},
24
+ "experiment_id": "exp-1",
25
+ "recipient_principal_id": "auth0|alice",
26
+ }
27
+ base.update(overrides)
28
+ return base
29
+
30
+
31
+ def test_list_returns_typed_events_and_unseen_count(requests_mock):
32
+ requests_mock.get(
33
+ f"{BASE}/v1/feed",
34
+ json=[_event()],
35
+ headers={"X-Has-More": "false", "X-Unseen-Count": "3"},
36
+ )
37
+
38
+ page = _chronicle().feed.list(category="activity")
39
+
40
+ assert page.next_page_token is None
41
+ assert page.unseen_count == 3
42
+ assert len(page.results) == 1
43
+ ev = page.results[0]
44
+ assert ev.seq == 42
45
+ assert ev.category == "activity"
46
+ assert ev.kind == "run.succeeded"
47
+ assert ev.payload == {"foo": "bar"}
48
+ assert ev.recipient_principal_id == "auth0|alice"
49
+
50
+
51
+ def test_list_omits_optional_fields(requests_mock):
52
+ """Optional resolution/score fields absent on the wire → None."""
53
+ requests_mock.get(
54
+ f"{BASE}/v1/feed",
55
+ json=[_event()],
56
+ headers={"X-Has-More": "false"},
57
+ )
58
+
59
+ ev = _chronicle().feed.list().results[0]
60
+ assert ev.resolved_at is None
61
+ assert ev.resolving_seq is None
62
+ assert ev.score is None
63
+ # Missing X-Unseen-Count header defaults to 0.
64
+ assert _chronicle().feed.list().unseen_count == 0
65
+
66
+
67
+ def test_list_builds_cursor_from_seq(requests_mock):
68
+ requests_mock.get(
69
+ f"{BASE}/v1/feed",
70
+ json=[_event(seq=42)],
71
+ headers={"X-Has-More": "true", "X-Unseen-Count": "1"},
72
+ )
73
+
74
+ page = _chronicle().feed.list()
75
+ assert page.next_page_token == "42"
76
+
77
+
78
+ def test_list_threads_filters_and_lenses(requests_mock):
79
+ requests_mock.get(
80
+ f"{BASE}/v1/feed",
81
+ json=[],
82
+ headers={"X-Has-More": "false"},
83
+ )
84
+
85
+ _chronicle().feed.list(
86
+ category=["report_approval", "experiment_invite"],
87
+ actionable=True,
88
+ open=True,
89
+ recipient="me",
90
+ experiment="exp-1",
91
+ kind="invite.received",
92
+ severity="warning",
93
+ after_seq=10,
94
+ limit=25,
95
+ order="score",
96
+ )
97
+
98
+ qs = requests_mock.last_request.qs
99
+ assert qs["category"] == ["report_approval,experiment_invite"]
100
+ assert qs["actionable"] == ["true"]
101
+ assert qs["open"] == ["true"]
102
+ assert qs["recipient"] == ["me"]
103
+ assert qs["experiment"] == ["exp-1"]
104
+ assert qs["kind"] == ["invite.received"]
105
+ assert qs["severity"] == ["warning"]
106
+ assert qs["after_seq"] == ["10"]
107
+ assert qs["limit"] == ["25"]
108
+ assert qs["order"] == ["score"]
109
+
110
+
111
+ def test_seen_posts_section_and_through_seq(requests_mock):
112
+ requests_mock.post(f"{BASE}/v1/feed/seen", status_code=204)
113
+
114
+ _chronicle().feed.seen(section="passive", through_seq=42)
115
+
116
+ assert requests_mock.last_request.json() == {
117
+ "section": "passive",
118
+ "through_seq": 42,
119
+ }
120
+
121
+
122
+ def test_iter_walks_before_cursor(requests_mock):
123
+ requests_mock.get(
124
+ f"{BASE}/v1/feed",
125
+ [
126
+ {"json": [_event(seq=42)], "headers": {"X-Has-More": "true"}},
127
+ {"json": [_event(seq=40)], "headers": {"X-Has-More": "false"}},
128
+ ],
129
+ )
130
+
131
+ seqs = [e.seq for e in _chronicle().feed.iter()]
132
+ assert seqs == [42, 40]
133
+ assert requests_mock.call_count == 2
134
+ second = requests_mock.request_history[1]
135
+ assert second.qs["before"] == ["42"]