methodic-research 0.1.2__py3-none-any.whl

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.
@@ -0,0 +1,342 @@
1
+ """Experiments namespace and resource handle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Any, Iterator
7
+
8
+ from methodic.reports import _BoundReports
9
+ from methodic.transport import Transport
10
+ from methodic.types import (
11
+ CreateExperimentResponse,
12
+ Experiment as ExperimentData,
13
+ ExperimentDetail,
14
+ ExperimentListPage,
15
+ ExperimentSummary,
16
+ GitStatus,
17
+ GitToken,
18
+ LineageResponse,
19
+ UpstreamRetractionsResponse,
20
+ )
21
+ from methodic.variations import _BoundVariations
22
+
23
+ if TYPE_CHECKING:
24
+ from methodic.chronicle import Chronicle
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class ExperimentsAPI:
30
+ """Experiments namespace. Stateless; every method takes the experiment id explicitly."""
31
+
32
+ def __init__(self, transport: Transport, chronicle: Chronicle) -> None:
33
+ self._t = transport
34
+ self._chronicle = chronicle
35
+
36
+ def create(
37
+ self,
38
+ *,
39
+ hypothesis_summary: str,
40
+ config_yaml: str,
41
+ rationale: str | None = None,
42
+ description: str | None = None,
43
+ accelerate_config_yaml: str | None = None,
44
+ launch_config: dict[str, Any] | None = None,
45
+ parent_experiment_ids: list[str] | None = None,
46
+ allow_retracted_parent: bool = False,
47
+ ) -> Experiment:
48
+ """Create a new experiment. Returns a handle with the create-response cached."""
49
+ payload: dict[str, Any] = {
50
+ "hypothesis_summary": hypothesis_summary,
51
+ "config_yaml": config_yaml,
52
+ }
53
+ if rationale is not None:
54
+ payload["rationale"] = rationale
55
+ if description is not None:
56
+ payload["description"] = description
57
+ if accelerate_config_yaml is not None:
58
+ payload["accelerate_config_yaml"] = accelerate_config_yaml
59
+ if launch_config is not None:
60
+ payload["launch_config"] = launch_config
61
+ if parent_experiment_ids is not None:
62
+ payload["parent_experiment_ids"] = parent_experiment_ids
63
+ if allow_retracted_parent:
64
+ payload["allow_retracted_parent"] = True
65
+
66
+ resp = self._t.post("/experiments", json=payload)
67
+ result = CreateExperimentResponse.from_dict(resp)
68
+ return Experiment(self._chronicle, result.experiment_id, _create_response=result)
69
+
70
+ def get(self, experiment_id: str) -> ExperimentDetail:
71
+ return ExperimentDetail.from_dict(self._t.get(f"/experiments/{experiment_id}"))
72
+
73
+ def get_rationale(self, experiment_id: str) -> str | None:
74
+ resp = self._t.get(f"/experiments/{experiment_id}/rationale")
75
+ return resp.get("rationale") if resp else None
76
+
77
+ def list(
78
+ self,
79
+ *,
80
+ status: str | None = None,
81
+ created_by: str | None = None,
82
+ page_size: int | None = None,
83
+ page_token: str | None = None,
84
+ ) -> ExperimentListPage:
85
+ """One page of experiments matching the filters. Cursor-aware (when server supports it)."""
86
+ params: dict[str, Any] = {}
87
+ if status is not None:
88
+ params["status"] = status
89
+ if created_by is not None:
90
+ params["created_by"] = created_by
91
+ if page_size is not None:
92
+ params["page_size"] = page_size
93
+ if page_token is not None:
94
+ params["page_token"] = page_token
95
+ return ExperimentListPage.from_dict(
96
+ self._t.get("/experiments", params=params or None)
97
+ )
98
+
99
+ def iter(
100
+ self,
101
+ *,
102
+ status: str | None = None,
103
+ created_by: str | None = None,
104
+ page_size: int | None = None,
105
+ ) -> Iterator[ExperimentSummary]:
106
+ """Yield every experiment matching the filters, paging server-side as needed."""
107
+ token: str | None = None
108
+ while True:
109
+ page = self.list(
110
+ status=status,
111
+ created_by=created_by,
112
+ page_size=page_size,
113
+ page_token=token,
114
+ )
115
+ yield from page.results
116
+ if page.next_page_token is None:
117
+ return
118
+ token = page.next_page_token
119
+
120
+ def commit(self, experiment_id: str) -> dict[str, Any]:
121
+ return self._t.put(f"/experiments/{experiment_id}/commit")
122
+
123
+ def conclude(self, experiment_id: str) -> dict[str, Any]:
124
+ return self._t.put(f"/experiments/{experiment_id}/conclude")
125
+
126
+ def retract(
127
+ self,
128
+ experiment_id: str,
129
+ *,
130
+ reason: str,
131
+ document_asset_id: str | None = None,
132
+ ) -> dict[str, Any]:
133
+ payload: dict[str, Any] = {"reason": reason}
134
+ if document_asset_id is not None:
135
+ payload["document_asset_id"] = document_asset_id
136
+ return self._t.put(f"/experiments/{experiment_id}/retract", json=payload)
137
+
138
+ def get_lineage(
139
+ self,
140
+ experiment_id: str,
141
+ *,
142
+ direction: str | None = None,
143
+ depth: int | None = None,
144
+ ) -> LineageResponse:
145
+ params: dict[str, Any] = {}
146
+ if direction is not None:
147
+ params["direction"] = direction
148
+ if depth is not None:
149
+ params["depth"] = depth
150
+ return LineageResponse.from_dict(
151
+ self._t.get(f"/experiments/{experiment_id}/lineage", params=params or None)
152
+ )
153
+
154
+ def git_status(self, experiment_id: str) -> GitStatus:
155
+ """Current git-integration state for the experiment.
156
+
157
+ Returns lightweight status info — `state` (pending/ready/failed/archived),
158
+ `repo_url` (when ready), `failure_reason` (when failed). Cheap to poll;
159
+ UI calls this every couple seconds while state is `pending`.
160
+ """
161
+ return GitStatus.from_dict(self._t.get(f"/experiments/{experiment_id}/git"))
162
+
163
+ def wait_for_repo(
164
+ self,
165
+ experiment_id: str,
166
+ *,
167
+ timeout: float = 300.0,
168
+ poll_interval: float = 2.0,
169
+ ) -> GitStatus:
170
+ """Poll `git_status` until the repo is `ready` or `failed`, or timeout."""
171
+ import time
172
+
173
+ deadline = time.monotonic() + timeout
174
+ while True:
175
+ status = self.git_status(experiment_id)
176
+ if status.state in ("ready", "failed", "archived"):
177
+ return status
178
+ if time.monotonic() >= deadline:
179
+ return status # caller checks state
180
+ time.sleep(poll_interval)
181
+
182
+ def mint_git_token(self, experiment_id: str) -> GitToken:
183
+ """Mint a 1-hour install token scoped to this experiment's repo.
184
+
185
+ The returned token has Administration permission stripped — pushes
186
+ to `agent/*` branches will be rejected by branch protection. Use it
187
+ to clone the repo and push to `user/...` branches you create.
188
+
189
+ Raises `ServerError(503)` if the server has no GitHub App configured;
190
+ `ConflictError(409)` if the experiment's repo isn't `ready` yet.
191
+ """
192
+ return GitToken.from_dict(self._t.post(f"/experiments/{experiment_id}/git/token"))
193
+
194
+ def get_upstream_retractions(
195
+ self,
196
+ experiment_id: str,
197
+ *,
198
+ depth: int | None = None,
199
+ since: str | None = None,
200
+ full_chain: bool = False,
201
+ ) -> UpstreamRetractionsResponse:
202
+ params: dict[str, Any] = {}
203
+ if depth is not None:
204
+ params["depth"] = depth
205
+ if since is not None:
206
+ params["since"] = since
207
+ if full_chain:
208
+ params["full_chain"] = "true"
209
+ return UpstreamRetractionsResponse.from_dict(
210
+ self._t.get(
211
+ f"/experiments/{experiment_id}/upstream-retractions",
212
+ params=params or None,
213
+ )
214
+ )
215
+
216
+
217
+ class Experiment:
218
+ """Handle for one experiment.
219
+
220
+ Mutators (`commit`, `conclude`, `retract`) return `self` so callers can
221
+ chain (`exp.commit().variations.create(...)`). Cached detail is dropped
222
+ after each mutation; the next attribute access re-fetches transparently.
223
+ """
224
+
225
+ def __init__(
226
+ self,
227
+ chronicle: Chronicle,
228
+ experiment_id: str,
229
+ *,
230
+ _detail: ExperimentDetail | None = None,
231
+ _create_response: CreateExperimentResponse | None = None,
232
+ ) -> None:
233
+ self._chronicle = chronicle
234
+ self.id = experiment_id
235
+ self._detail = _detail
236
+ self._create_response = _create_response
237
+ self.variations = _BoundVariations(chronicle.variations, experiment_id)
238
+ self.reports = _BoundReports(chronicle.reports, experiment_id)
239
+
240
+ @property
241
+ def detail(self) -> ExperimentDetail:
242
+ if self._detail is None:
243
+ self._detail = self._chronicle.experiments.get(self.id)
244
+ return self._detail
245
+
246
+ @property
247
+ def data(self) -> ExperimentData:
248
+ return self.detail.experiment
249
+
250
+ @property
251
+ def state(self) -> str:
252
+ return self.data.state
253
+
254
+ @property
255
+ def hypothesis_summary(self) -> str:
256
+ return self.data.hypothesis_summary
257
+
258
+ @property
259
+ def committed_at(self) -> str | None:
260
+ return self.data.committed_at
261
+
262
+ @property
263
+ def concluded_at(self) -> str | None:
264
+ return self.data.concluded_at
265
+
266
+ @property
267
+ def retracted_at(self) -> str | None:
268
+ return self.data.retracted_at
269
+
270
+ def get_rationale(self) -> str | None:
271
+ return self._chronicle.experiments.get_rationale(self.id)
272
+
273
+ def git_status(self) -> GitStatus:
274
+ """Lightweight current git-integration state for this experiment."""
275
+ return self._chronicle.experiments.git_status(self.id)
276
+
277
+ def wait_for_repo(
278
+ self, *, timeout: float = 300.0, poll_interval: float = 2.0
279
+ ) -> GitStatus:
280
+ """Poll until this experiment's repo is `ready` (or `failed`/timeout)."""
281
+ return self._chronicle.experiments.wait_for_repo(
282
+ self.id, timeout=timeout, poll_interval=poll_interval
283
+ )
284
+
285
+ def mint_git_token(self) -> GitToken:
286
+ """Mint a 1-hour install token scoped to this experiment's repo."""
287
+ return self._chronicle.experiments.mint_git_token(self.id)
288
+
289
+ def commit(self) -> Experiment:
290
+ self._chronicle.experiments.commit(self.id)
291
+ self._detail = None
292
+ return self
293
+
294
+ def conclude(self) -> Experiment:
295
+ self._chronicle.experiments.conclude(self.id)
296
+ self._detail = None
297
+ return self
298
+
299
+ def retract(
300
+ self, *, reason: str, document_asset_id: str | None = None
301
+ ) -> Experiment:
302
+ self._chronicle.experiments.retract(
303
+ self.id, reason=reason, document_asset_id=document_asset_id
304
+ )
305
+ self._detail = None
306
+ return self
307
+
308
+ def get_lineage(
309
+ self, *, direction: str | None = None, depth: int | None = None
310
+ ) -> LineageResponse:
311
+ return self._chronicle.experiments.get_lineage(
312
+ self.id, direction=direction, depth=depth
313
+ )
314
+
315
+ def get_upstream_retractions(
316
+ self,
317
+ *,
318
+ depth: int | None = None,
319
+ since: str | None = None,
320
+ full_chain: bool = False,
321
+ ) -> UpstreamRetractionsResponse:
322
+ return self._chronicle.experiments.get_upstream_retractions(
323
+ self.id, depth=depth, since=since, full_chain=full_chain
324
+ )
325
+
326
+ def set_report_settings(self, settings: dict[str, Any]) -> Experiment:
327
+ """Replace `experiment.report_settings`. Frozen at commit (server
328
+ returns 409 once committed). `settings` shape matches the
329
+ `ReportSettings` server type:
330
+
331
+ {
332
+ "hypothesis": {"mode": "freeform", "freeform_prompt": "..."},
333
+ "takeaways": {"mode": "template", "template_asset_id": "...", "per_variation": true},
334
+ "research": {...}
335
+ }
336
+
337
+ Returns `self` for chaining. Drops cached `_detail` so the next
338
+ access re-fetches the updated row.
339
+ """
340
+ self._chronicle.reports.update_settings(self.id, settings=settings)
341
+ self._detail = None
342
+ return self
methodic/reports.py ADDED
@@ -0,0 +1,294 @@
1
+ """Reports namespace: render hypothesis / takeaways / research, check
2
+ compile status, download the rendered PDF.
3
+
4
+ Usage:
5
+
6
+ chronicle = Chronicle(server_url=..., api_key=...)
7
+ exp = chronicle.experiments.create(hypothesis_summary="...", config_yaml="...")
8
+
9
+ # Template mode (default).
10
+ report = exp.reports.hypothesis.render(payload={
11
+ "title": "Ripple study",
12
+ "abstract": "...",
13
+ "hypothesis": "...",
14
+ "motivation": "...",
15
+ "plan": ["sweep coefficient", "measure convergence"],
16
+ })
17
+
18
+ # The render endpoint compiles synchronously when the chronicle-tex
19
+ # service is configured; check the result.
20
+ if report.compile_status == "compiled":
21
+ report.download_pdf("hypothesis.pdf")
22
+
23
+ # Free-form mode (per-experiment opt-in via report_settings).
24
+ exp.set_report_settings(hypothesis={
25
+ "mode": "freeform",
26
+ "freeform_prompt": "Write a one-page hypothesis focusing on...",
27
+ })
28
+ report = exp.reports.hypothesis.render(tex_body=open("draft.tex").read())
29
+
30
+ See `runes/chronicle/designs/reports.md` for the full design.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import base64
36
+ import logging
37
+ import time
38
+ from typing import TYPE_CHECKING, Any
39
+
40
+ from methodic.transport import Transport
41
+
42
+ if TYPE_CHECKING:
43
+ from methodic.chronicle import Chronicle
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # Outcomes the gates accept; everything else is in-flight or terminal-fail.
48
+ _TERMINAL_COMPILE_STATUSES = ("compiled", "failed", "stale")
49
+
50
+
51
+ class ReportsAPI:
52
+ """Reports namespace. Stateless; every method takes the experiment id
53
+ + report kind explicitly. Most users access via `exp.reports` instead
54
+ of calling these methods directly."""
55
+
56
+ def __init__(self, transport: Transport, chronicle: Chronicle) -> None:
57
+ self._t = transport
58
+ self._chronicle = chronicle
59
+
60
+ def render(
61
+ self,
62
+ experiment_id: str,
63
+ kind: str,
64
+ *,
65
+ payload: dict[str, Any] | None = None,
66
+ tex_body: str | None = None,
67
+ template_asset_id: str | None = None,
68
+ ) -> Report:
69
+ """Render (and compile, when chronicle-tex is configured) a report.
70
+
71
+ Pass `payload` for template mode; `tex_body` for freeform mode.
72
+ Mode is selected by the experiment's `report_settings.{kind}.mode`
73
+ — these args feed the chosen mode and are ignored otherwise.
74
+ """
75
+ body: dict[str, Any] = {}
76
+ if payload is not None:
77
+ body["payload"] = payload
78
+ if tex_body is not None:
79
+ body["tex_body"] = tex_body
80
+ if template_asset_id is not None:
81
+ body["template_asset_id"] = template_asset_id
82
+
83
+ resp = self._t.post(
84
+ f"/experiments/{experiment_id}/reports/{kind}/render", json=body
85
+ )
86
+ return Report(self._chronicle, asset_dict=resp)
87
+
88
+ def get(self, asset_id: str) -> Report:
89
+ """Fetch a report by its asset id. Returns the asset metadata
90
+ plus content (compile_status, log, pdf_b64 if compiled)."""
91
+ resp = self._t.get(f"/v1/reports/{asset_id}")
92
+ return Report(self._chronicle, full_dict=resp)
93
+
94
+ def update_settings(
95
+ self,
96
+ experiment_id: str,
97
+ *,
98
+ settings: dict[str, Any],
99
+ ) -> dict[str, Any]:
100
+ """Replace `experiment.report_settings` with `settings`. Frozen at
101
+ commit — server returns 409 once the experiment is committed."""
102
+ return self._t.put(
103
+ f"/v1/experiments/{experiment_id}/report-settings",
104
+ json={"settings": settings},
105
+ )
106
+
107
+
108
+ class Report:
109
+ """Handle for one rendered report asset.
110
+
111
+ Wraps the asset metadata plus (when fetched via `reports.get` or
112
+ `refresh`) the json_asset_store content with `compile_status`, `log`,
113
+ and `pdf_b64`. Mutators return `self` for chaining.
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ chronicle: Chronicle,
119
+ *,
120
+ asset_dict: dict[str, Any] | None = None,
121
+ full_dict: dict[str, Any] | None = None,
122
+ ) -> None:
123
+ self._chronicle = chronicle
124
+ if full_dict is not None:
125
+ self._asset = full_dict["asset"]
126
+ self._content = full_dict.get("content")
127
+ else:
128
+ assert asset_dict is not None
129
+ self._asset = asset_dict
130
+ self._content = None
131
+
132
+ @property
133
+ def id(self) -> str:
134
+ return self._asset["id"]
135
+
136
+ @property
137
+ def asset_type(self) -> str:
138
+ return self._asset["asset_type"]
139
+
140
+ @property
141
+ def name(self) -> str:
142
+ return self._asset["name"]
143
+
144
+ @property
145
+ def asset_config(self) -> dict[str, Any]:
146
+ return self._asset.get("asset_config") or {}
147
+
148
+ @property
149
+ def compile_status(self) -> str:
150
+ """Current `compile_status`: pending / compiled / failed / stale.
151
+
152
+ Reads from the asset's `asset_config.compile_status` (always
153
+ present on render-pipeline-produced assets); for content-fetched
154
+ reports also cross-references the json_asset_store body.
155
+ """
156
+ if self._content is not None:
157
+ cs = self._content.get("compile_status")
158
+ if isinstance(cs, str):
159
+ return cs
160
+ return str(self.asset_config.get("compile_status", "unknown"))
161
+
162
+ @property
163
+ def template_asset_id(self) -> str | None:
164
+ v = self.asset_config.get("template_asset_id")
165
+ return v if isinstance(v, str) else None
166
+
167
+ @property
168
+ def mode(self) -> str:
169
+ return str(self.asset_config.get("mode", "unknown"))
170
+
171
+ @property
172
+ def compile_request_id(self) -> str | None:
173
+ v = self.asset_config.get("compile_request_id")
174
+ return v if isinstance(v, str) else None
175
+
176
+ @property
177
+ def compile_log(self) -> str | None:
178
+ """Tectonic stdout/stderr — only available when content has been
179
+ fetched (via `refresh()` or `chronicle.reports.get(...)`)."""
180
+ if self._content is None:
181
+ return None
182
+ v = self._content.get("compile_log")
183
+ return v if isinstance(v, str) else None
184
+
185
+ def refresh(self) -> Report:
186
+ """Re-fetch the report's asset + content. Returns `self` for
187
+ chaining (e.g. `report.refresh().compile_status`)."""
188
+ latest = self._chronicle.reports.get(self.id)
189
+ self._asset = latest._asset
190
+ self._content = latest._content
191
+ return self
192
+
193
+ def wait_for_compile(
194
+ self,
195
+ *,
196
+ timeout: float = 60.0,
197
+ poll_interval: float = 1.0,
198
+ ) -> Report:
199
+ """Poll `refresh` until `compile_status` is terminal (compiled,
200
+ failed, stale) or `timeout` elapses. Returns `self`; check
201
+ `.compile_status` on the result.
202
+
203
+ On most deployments the render endpoint compiles synchronously, so
204
+ this returns immediately on the first refresh. Useful for
205
+ deployments where the compile worker is configured to run
206
+ out-of-band (or for the M4-style stub where compile_status starts
207
+ at `pending` and never advances — in that case this times out)."""
208
+ deadline = time.monotonic() + timeout
209
+ while True:
210
+ self.refresh()
211
+ if self.compile_status in _TERMINAL_COMPILE_STATUSES:
212
+ return self
213
+ if time.monotonic() >= deadline:
214
+ logger.warning(
215
+ "wait_for_compile timed out after %.1fs (status=%s)",
216
+ timeout,
217
+ self.compile_status,
218
+ )
219
+ return self
220
+ time.sleep(poll_interval)
221
+
222
+ def download_pdf(self, path: str | None = None) -> bytes:
223
+ """Download the compiled PDF. If `path` is given, write it there.
224
+ Returns the PDF bytes regardless. Raises `RuntimeError` if the
225
+ compile hasn't succeeded or no PDF is available.
226
+
227
+ Will fetch content if not already loaded (i.e. one extra round
228
+ trip when called on a Report obtained from `render` rather than
229
+ `get`)."""
230
+ if self._content is None:
231
+ self.refresh()
232
+ assert self._content is not None
233
+ if self.compile_status != "compiled":
234
+ raise RuntimeError(
235
+ f"cannot download PDF: compile_status is {self.compile_status!r}"
236
+ )
237
+ pdf_b64 = self._content.get("pdf_b64")
238
+ if not isinstance(pdf_b64, str) or not pdf_b64:
239
+ raise RuntimeError(
240
+ "report has no pdf_b64 content — was it compiled by this server?"
241
+ )
242
+ pdf_bytes = base64.b64decode(pdf_b64)
243
+ if path is not None:
244
+ with open(path, "wb") as f:
245
+ f.write(pdf_bytes)
246
+ logger.debug("Wrote %d bytes of PDF to %s", len(pdf_bytes), path)
247
+ return pdf_bytes
248
+
249
+ def download_source(self) -> str:
250
+ """Return the rendered `.tex` source. Loads content on first call."""
251
+ if self._content is None:
252
+ self.refresh()
253
+ assert self._content is not None
254
+ rendered = self._content.get("rendered_tex") or self._content.get("tex_body")
255
+ if not isinstance(rendered, str):
256
+ raise RuntimeError("report has no rendered_tex / tex_body content")
257
+ return rendered
258
+
259
+
260
+ class _BoundReportKind:
261
+ """Per-(experiment, kind) report shim — what `exp.reports.hypothesis`
262
+ returns. Provides `.render(...)` without re-passing the experiment id
263
+ or kind every call."""
264
+
265
+ def __init__(self, api: ReportsAPI, experiment_id: str, kind: str) -> None:
266
+ self._api = api
267
+ self._experiment_id = experiment_id
268
+ self._kind = kind
269
+
270
+ def render(
271
+ self,
272
+ *,
273
+ payload: dict[str, Any] | None = None,
274
+ tex_body: str | None = None,
275
+ template_asset_id: str | None = None,
276
+ ) -> Report:
277
+ return self._api.render(
278
+ self._experiment_id,
279
+ self._kind,
280
+ payload=payload,
281
+ tex_body=tex_body,
282
+ template_asset_id=template_asset_id,
283
+ )
284
+
285
+
286
+ class _BoundReports:
287
+ """Per-experiment reports shim — what `exp.reports` returns. Exposes
288
+ one `_BoundReportKind` per kind: `.hypothesis`, `.takeaways`,
289
+ `.research`."""
290
+
291
+ def __init__(self, api: ReportsAPI, experiment_id: str) -> None:
292
+ self.hypothesis = _BoundReportKind(api, experiment_id, "hypothesis")
293
+ self.takeaways = _BoundReportKind(api, experiment_id, "takeaways")
294
+ self.research = _BoundReportKind(api, experiment_id, "research")