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.
- {methodic_research-0.35.0/src/methodic_research.egg-info → methodic_research-0.37.0}/PKG-INFO +1 -1
- {methodic_research-0.35.0 → methodic_research-0.37.0}/pyproject.toml +1 -1
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/__init__.py +8 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/chronicle.py +4 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/experiments.py +65 -3
- methodic_research-0.37.0/src/methodic/feed.py +170 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/types.py +113 -3
- {methodic_research-0.35.0 → methodic_research-0.37.0/src/methodic_research.egg-info}/PKG-INFO +1 -1
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/SOURCES.txt +2 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_experiments.py +96 -0
- methodic_research-0.37.0/tests/test_feed.py +135 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/LICENSE +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/README.md +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/setup.cfg +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/activity.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/api_keys.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/assets.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/cli.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/collections.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/datasets.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/errata.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/errors.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/feedback.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/imports.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/me.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/pending.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/publications.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/reports.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/research_prompts.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/runs.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/search.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/tags.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/transport.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/upload_tracker.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic/variations.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/dependency_links.txt +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/entry_points.txt +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/requires.txt +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/top_level.txt +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_activity.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_api_keys.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_assets.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_cli.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_client.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_collections.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_config.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_datasets.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_distill.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_feedback.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_imports.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_pending.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_pending_reasons.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_reports.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_research_prompts.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_runs.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_search.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_upload_tracker.py +0 -0
- {methodic_research-0.35.0 → methodic_research-0.37.0}/tests/test_variations.py +0 -0
|
@@ -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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
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`.
|
{methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/SOURCES.txt
RENAMED
|
@@ -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"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/requires.txt
RENAMED
|
File without changes
|
{methodic_research-0.35.0 → methodic_research-0.37.0}/src/methodic_research.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|