proofstep-cli 0.1.0__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,541 @@
1
+ """Send a completed run to the server.
2
+
3
+ Publishing is what turns a CI job's exit code into a record: the dataset it ran against, the
4
+ examples and their scores, the gate verdict, and the comparison to the baseline — all durable, all
5
+ tied to a commit. Without it the run exists for as long as the CI log is retained, and the dashboard
6
+ can only show production traffic, never the history of what CI decided.
7
+
8
+ Four rules this module is built on, in order of importance:
9
+
10
+ 1. **Publishing never changes the verdict.** The exit code comes from the local evaluation, which
11
+ already ran. A server that is slow, unreachable, or misconfigured must not be able to turn a
12
+ failing run into a passing one — or a passing one into a failure — because that would make the
13
+ gate depend on infrastructure rather than on the code being merged.
14
+
15
+ 2. **A failure to publish is reported, never swallowed.** The whole point is a durable record; a
16
+ run that silently did not produce one is worse than an obvious error, because nobody looks for
17
+ the thing they believe exists. `--require-publish` escalates it to a hard failure for teams whose
18
+ process depends on the record.
19
+
20
+ 3. **Dataset versions are content-addressed.** The version label is derived from the content hash,
21
+ so identical data always resolves to the same version and changed data always creates a new one.
22
+ That is what makes `dataset_match` mean something: a comparison across two different datasets is
23
+ refused rather than quietly reported.
24
+
25
+ 4. **The server's verdict is checked against the local one.** They are computed by the same code
26
+ from the same numbers, so they must agree — and if they ever do not, that is the single most
27
+ important bug in the system, because the exit code CI acted on and the verdict the dashboard
28
+ shows would have disagreed. `apps/api/tests/test_parity.py` guards this from the other side;
29
+ this check is the runtime backstop.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from dataclasses import dataclass, field
36
+ from typing import TYPE_CHECKING, Any
37
+
38
+ import httpx
39
+
40
+ from proofstep_types import ExampleResult, Metric
41
+
42
+ if TYPE_CHECKING:
43
+ from proofstep_cli.suite.loader import LoadedSuite
44
+ from proofstep_core.dataset import Dataset
45
+ from proofstep_core.runner import EvalResult
46
+
47
+ #: Batch sizes the API accepts. Enforced there by the wire models; mirrored here so a large suite is
48
+ #: chunked rather than rejected.
49
+ EXAMPLES_PER_REQUEST = 1_000
50
+ RESULTS_PER_REQUEST = 500
51
+
52
+ #: Cap on baseline results fetched for a paired test. Large enough for any suite anyone runs in CI,
53
+ #: and a bound rather than unbounded because a comparison that silently used half of one side would
54
+ #: be biased in a way nobody could see.
55
+ MAX_BASELINE_RESULTS = 5_000
56
+
57
+ DEFAULT_TIMEOUT = 60.0
58
+
59
+
60
+ class PublishError(RuntimeError):
61
+ """Publishing failed. Never raised out of `publish()` — carried on the outcome instead."""
62
+
63
+
64
+ @dataclass
65
+ class PublishOutcome:
66
+ """What publishing did, or why it did not happen.
67
+
68
+ Returned rather than raised because the caller has already computed a verdict and must report it
69
+ regardless. Every field here is designed to be printed: a publish outcome nobody can read is the
70
+ same as no record at all.
71
+ """
72
+
73
+ published: bool = False
74
+ skipped_reason: str | None = None
75
+ error: str | None = None
76
+
77
+ experiment_id: str | None = None
78
+ run_id: str | None = None
79
+ experiment_url: str | None = None
80
+ dataset_version_id: str | None = None
81
+ baseline_run_id: str | None = None
82
+
83
+ server_verdict: str | None = None
84
+ server_exit_code: int | None = None
85
+ #: Ways the server's answer differed from the local one. Non-empty means a real bug, not a
86
+ #: configuration problem — see rule 4 in the module docstring.
87
+ divergences: list[str] = field(default_factory=list)
88
+
89
+
90
+ def slugify(value: str) -> str:
91
+ """A dataset slug the API will accept: lowercase, alphanumeric, hyphen-separated.
92
+
93
+ Derived from the suite's dataset reference rather than asked for in the suite file, because a
94
+ slug is an implementation detail of the server and making people invent one is a step that adds
95
+ no information.
96
+ """
97
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
98
+ return (slug or "dataset")[:100]
99
+
100
+
101
+ def version_label(content_hash: str) -> str:
102
+ """Content-addressed version label.
103
+
104
+ `sha-<12 hex>` rather than an incrementing number or a timestamp. Two runs over identical data
105
+ resolve to the same version — so a baseline comparison is against the same examples, provably —
106
+ and changed data cannot reuse a label, which is the failure that makes a "regression" actually a
107
+ dataset edit nobody noticed.
108
+ """
109
+ return f"sha-{content_hash[:12]}"
110
+
111
+
112
+ class Publisher:
113
+ """One run's worth of HTTP calls, in the order they have to happen."""
114
+
115
+ def __init__(self, endpoint: str, api_key: str, *, timeout: float = DEFAULT_TIMEOUT) -> None:
116
+ self.endpoint = endpoint.rstrip("/")
117
+ self._client = httpx.Client(
118
+ base_url=self.endpoint,
119
+ headers={"authorization": f"Bearer {api_key}"},
120
+ timeout=timeout,
121
+ )
122
+
123
+ def close(self) -> None:
124
+ self._client.close()
125
+
126
+ def __enter__(self) -> Publisher:
127
+ return self
128
+
129
+ def __exit__(self, *_: object) -> None:
130
+ self.close()
131
+
132
+ # ------------------------------------------------------------------ plumbing
133
+
134
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
135
+ try:
136
+ response = self._client.request(method, path, **kwargs)
137
+ except httpx.HTTPError as exc:
138
+ msg = f"{method} {path}: {type(exc).__name__}: {exc}"
139
+ raise PublishError(msg) from exc
140
+ if response.status_code >= 400:
141
+ # The API's error bodies are RFC 9457 problem documents; `detail` is the sentence
142
+ # written for a human, and quoting it beats reprinting a status code.
143
+ detail = ""
144
+ try:
145
+ detail = str(response.json().get("detail") or response.text)
146
+ except ValueError:
147
+ detail = response.text
148
+ msg = f"{method} {path} → {response.status_code}: {detail[:300]}"
149
+ raise PublishError(msg)
150
+ return response.json() if response.content else None
151
+
152
+ def get(self, path: str, **kwargs: Any) -> Any:
153
+ return self._request("GET", path, **kwargs)
154
+
155
+ def post(self, path: str, **kwargs: Any) -> Any:
156
+ return self._request("POST", path, **kwargs)
157
+
158
+ # ------------------------------------------------------------------- dataset
159
+
160
+ def ensure_dataset(self, *, name: str, slug: str) -> str:
161
+ """Create the dataset, or find the one that is already there.
162
+
163
+ The API refuses a duplicate slug with 409 rather than returning the existing row, which is
164
+ right for an API — a silent "created" for something that existed hides a mistake — and means
165
+ the client is responsible for the read-then-create. The read comes second: creating first
166
+ and catching the conflict is one round trip in the common case rather than two, and it has
167
+ no race worth worrying about because the loser reads the winner's row.
168
+ """
169
+ try:
170
+ created = self.post("/v1/datasets", json={"name": name, "slug": slug, "kind": "golden"})
171
+ except PublishError as exc:
172
+ if "409" not in str(exc):
173
+ raise
174
+ else:
175
+ return str(created["id"])
176
+
177
+ for dataset in self.get("/v1/datasets"):
178
+ if dataset["slug"] == slug:
179
+ return str(dataset["id"])
180
+ msg = f"dataset {slug!r} could not be created and could not be found"
181
+ raise PublishError(msg)
182
+
183
+ def ensure_version(self, *, dataset_id: str, slug: str, dataset: Dataset) -> str:
184
+ """A locked dataset version whose content hash matches the local data.
185
+
186
+ Resolved by content-addressed label first, so a repeat run of the same suite over unchanged
187
+ data reuses the version rather than uploading it again — which for a large dataset is the
188
+ difference between a publish that takes a second and one that takes a minute.
189
+ """
190
+ local_hash = dataset.content_hash
191
+ label = version_label(local_hash)
192
+
193
+ try:
194
+ existing = self.get(
195
+ "/v1/dataset-versions/resolve", params={"dataset": slug, "version": label}
196
+ )
197
+ except PublishError as exc:
198
+ if "404" not in str(exc):
199
+ raise
200
+ else:
201
+ self._assert_hash(existing, local_hash, label)
202
+ return str(existing["id"])
203
+
204
+ version = self.post(f"/v1/datasets/{dataset_id}/versions", json={"version": label})
205
+ version_id = str(version["id"])
206
+
207
+ examples = list(dataset)
208
+ for start in range(0, len(examples), EXAMPLES_PER_REQUEST):
209
+ chunk = examples[start : start + EXAMPLES_PER_REQUEST]
210
+ self.post(
211
+ f"/v1/dataset-versions/{version_id}/examples",
212
+ json={"examples": [example.model_dump(mode="json") for example in chunk]},
213
+ )
214
+
215
+ locked = self.post(f"/v1/dataset-versions/{version_id}/lock", json={})
216
+ self._assert_hash(locked, local_hash, label)
217
+ return version_id
218
+
219
+ def _assert_hash(self, version: dict[str, Any], local_hash: str, label: str) -> None:
220
+ """Refuse to continue when the server's hash of the same data differs.
221
+
222
+ This should be impossible — both sides call `proofstep_types.content_hash` over the same
223
+ examples — which is exactly why it is worth checking. If canonicalisation ever drifts, every
224
+ downstream comparison silently becomes a comparison across different data, and the
225
+ `dataset_match` guard that exists to prevent that would itself be lying.
226
+ """
227
+ remote = version.get("content_hash")
228
+ if remote and remote != local_hash:
229
+ msg = (
230
+ f"dataset hash mismatch on version {label!r}: the server computed {remote[:12]}… "
231
+ f"and this run computed {local_hash[:12]}…. Publishing would record a comparison "
232
+ "across different data. This is a bug in canonicalisation, not a configuration "
233
+ "problem."
234
+ )
235
+ raise PublishError(msg)
236
+
237
+ # ---------------------------------------------------------------- gate set
238
+
239
+ def ensure_gate_set(self, loaded: LoadedSuite) -> str | None:
240
+ """Mirror the suite's gates server-side, so the server gates on what the repository says.
241
+
242
+ Sent as the shared `GateRule` shape — severity included. The server used to have no wire
243
+ representation for `severity`, which turned every warning into a blocking rule; that is
244
+ fixed, and `apps/api/tests/test_parity.py` now guards the whole model.
245
+ """
246
+ from proofstep_cli.runner import build_gate_set # noqa: PLC0415 — avoids a cycle
247
+
248
+ gate_set = build_gate_set(loaded)
249
+ if gate_set is None or not gate_set.rules:
250
+ # A suite with no gates still publishes its results — the record is worth having even
251
+ # when nothing is being enforced yet, and that is a common way to adopt this.
252
+ return None
253
+
254
+ created = self.post(
255
+ "/v1/quality-gate-sets",
256
+ json={
257
+ "name": loaded.suite.name,
258
+ "require_dataset_match": gate_set.require_dataset_match,
259
+ "require_calibration": (
260
+ gate_set.require_calibration
261
+ if isinstance(gate_set.require_calibration, bool)
262
+ else gate_set.require_calibration.model_dump(mode="json")
263
+ ),
264
+ "rules": [
265
+ {
266
+ "metric_key": rule.metric_key,
267
+ "minimum": rule.minimum,
268
+ "maximum": rule.maximum,
269
+ "max_absolute_regression": rule.max_absolute_regression,
270
+ "max_relative_regression": rule.max_relative_regression,
271
+ "severity": rule.severity.value,
272
+ "slice": rule.slice,
273
+ "require_baseline": rule.require_baseline,
274
+ "max_error_rate": rule.max_error_rate,
275
+ }
276
+ for rule in gate_set.rules
277
+ ],
278
+ },
279
+ )
280
+ return str(created["id"])
281
+
282
+ # -------------------------------------------------------------- experiment
283
+
284
+ def record_run(
285
+ self,
286
+ *,
287
+ loaded: LoadedSuite,
288
+ result: EvalResult,
289
+ dataset_version_id: str,
290
+ gate_set_id: str | None,
291
+ git: tuple[str | None, str | None, bool],
292
+ ) -> tuple[str, str]:
293
+ commit, branch, dirty = git
294
+ experiment = self.post(
295
+ "/v1/experiments",
296
+ json={
297
+ "name": loaded.suite.name,
298
+ "suite_name": loaded.suite.name,
299
+ "dataset_version_id": dataset_version_id,
300
+ "gate_set_id": gate_set_id,
301
+ "task_ref": loaded.suite.task.entrypoint if loaded.suite.task else None,
302
+ "git_commit": commit,
303
+ "git_branch": branch,
304
+ # Recorded, not refused. A dirty tree is normal locally and meaningful in CI, and
305
+ # the honest move is to say so on the record rather than to reject the run.
306
+ "git_dirty": dirty,
307
+ },
308
+ )
309
+ experiment_id = str(experiment["id"])
310
+
311
+ run = self.post(f"/v1/experiments/{experiment_id}/runs", params={"trigger": "cli"})
312
+ run_id = str(run["id"])
313
+
314
+ for start in range(0, len(result.results), RESULTS_PER_REQUEST):
315
+ chunk = result.results[start : start + RESULTS_PER_REQUEST]
316
+ self.post(
317
+ f"/v1/experiment-runs/{run_id}/results",
318
+ json={"results": [row.model_dump(mode="json") for row in chunk]},
319
+ )
320
+
321
+ # Completing is what triggers server-side aggregation. A run left open has results and no
322
+ # metrics, which reads as "the suite produced nothing".
323
+ status = "failed" if result.aborted_reason else "succeeded"
324
+ self.post(
325
+ f"/v1/experiment-runs/{run_id}/complete",
326
+ json={"status": status, "error": result.aborted_reason},
327
+ )
328
+
329
+ # After completing, never before: completing recomputes the run's aggregates from the stored
330
+ # scores and clears what was there, so metrics submitted first would be deleted.
331
+ #
332
+ # Everything is offered; the server keeps only what it could not compute for itself and
333
+ # names the rest. That split is deliberate — see `ExperimentService.submit_metrics`. Without
334
+ # this call a suite that gates on a corpus metric (a protected class's recall, p95 latency)
335
+ # publishes and then reads as ERROR on the server, because those metrics cannot be derived
336
+ # from per-example scores by anyone but the process that ran the suite.
337
+ self.post(
338
+ f"/v1/experiment-runs/{run_id}/metrics",
339
+ json={"metrics": [metric.model_dump(mode="json") for metric in result.metrics]},
340
+ )
341
+ return experiment_id, run_id
342
+
343
+ def fetch_baseline(self, *, suite_name: str, branch: str) -> dict[str, Any]:
344
+ return dict(
345
+ self.get(
346
+ "/v1/experiments/baseline",
347
+ params={"suite_name": suite_name, "branch": branch},
348
+ )
349
+ )
350
+
351
+ def compare(
352
+ self, *, run_id: str, gate_set_id: str | None, baseline_branch: str
353
+ ) -> dict[str, Any]:
354
+ return dict(
355
+ self.post(
356
+ "/v1/experiments/compare",
357
+ json={
358
+ "candidate_run_id": run_id,
359
+ "gate_set_id": gate_set_id,
360
+ "baseline_branch": baseline_branch,
361
+ },
362
+ )
363
+ )
364
+
365
+
366
+ def publish(
367
+ loaded: LoadedSuite,
368
+ result: EvalResult,
369
+ dataset: Dataset,
370
+ *,
371
+ endpoint: str,
372
+ api_key: str,
373
+ git: tuple[str | None, str | None, bool],
374
+ timeout: float = DEFAULT_TIMEOUT,
375
+ ) -> PublishOutcome:
376
+ """Record this run on the server. Never raises; failures land on the outcome.
377
+
378
+ Deliberately not async and not concurrent. Every step here depends on the previous one's id, so
379
+ concurrency would buy nothing, and this runs after an evaluation that already took the time it
380
+ took.
381
+ """
382
+ outcome = PublishOutcome()
383
+ try:
384
+ with Publisher(endpoint, api_key, timeout=timeout) as publisher:
385
+ slug = slugify(loaded.suite.dataset.name or loaded.suite.name)
386
+ dataset_id = publisher.ensure_dataset(name=loaded.suite.name, slug=slug)
387
+ version_id = publisher.ensure_version(dataset_id=dataset_id, slug=slug, dataset=dataset)
388
+ outcome.dataset_version_id = version_id
389
+
390
+ gate_set_id = publisher.ensure_gate_set(loaded)
391
+ experiment_id, run_id = publisher.record_run(
392
+ loaded=loaded,
393
+ result=result,
394
+ dataset_version_id=version_id,
395
+ gate_set_id=gate_set_id,
396
+ git=git,
397
+ )
398
+ outcome.experiment_id = experiment_id
399
+ outcome.run_id = run_id
400
+ outcome.experiment_url = f"{publisher.endpoint}/v1/experiments/{experiment_id}"
401
+
402
+ compared = publisher.compare(
403
+ run_id=run_id,
404
+ gate_set_id=gate_set_id,
405
+ baseline_branch=loaded.suite.baseline.branch,
406
+ )
407
+ outcome.baseline_run_id = compared.get("baseline_run_id")
408
+ outcome.server_verdict = compared.get("verdict")
409
+ outcome.server_exit_code = compared.get("exit_code")
410
+ outcome.divergences = _divergences(result, compared)
411
+ outcome.published = True
412
+ except PublishError as exc:
413
+ outcome.error = str(exc)
414
+ except Exception as exc:
415
+ outcome.error = f"{type(exc).__name__}: {exc}"
416
+ return outcome
417
+
418
+
419
+ def _divergences(result: EvalResult, compared: dict[str, Any]) -> list[str]:
420
+ """Where the server's answer differs from the one this process already reported.
421
+
422
+ A first run has no baseline, so regression rules skip on both sides and the verdicts match. When
423
+ a baseline *does* exist server-side and did not locally, the server can legitimately reach a
424
+ stricter verdict — that is not a divergence, it is the server knowing more, so it is reported as
425
+ context rather than as a disagreement.
426
+ """
427
+ notes: list[str] = []
428
+ server_verdict = compared.get("verdict")
429
+ local_verdict = result.gates.verdict.value
430
+
431
+ if server_verdict is None:
432
+ return notes
433
+
434
+ if server_verdict != local_verdict:
435
+ extra = (
436
+ " (the server resolved a baseline this run did not have, so a regression rule it "
437
+ "could apply was skipped here)"
438
+ if compared.get("baseline_run_id")
439
+ else ""
440
+ )
441
+ notes.append(
442
+ f"verdict differs: this run reported {local_verdict!r}, the server {server_verdict!r}"
443
+ f"{extra}"
444
+ )
445
+ return notes
446
+
447
+
448
+ @dataclass
449
+ class Baseline:
450
+ """The run this one will be measured against, or an explicit absence."""
451
+
452
+ run_id: str | None = None
453
+ git_commit: str | None = None
454
+ metrics: list[Metric] = field(default_factory=list)
455
+ #: The baseline's per-example results, when they could be fetched. Needed for a *paired*
456
+ #: significance test — aggregates cannot support one, because pairing is what makes the test
457
+ #: sensitive enough to be worth running at eval sample sizes.
458
+ results: list[ExampleResult] = field(default_factory=list)
459
+ error: str | None = None
460
+
461
+ @property
462
+ def label(self) -> str | None:
463
+ if self.run_id is None:
464
+ return None
465
+ return f"{self.git_commit[:7]} ({self.run_id[:8]})" if self.git_commit else self.run_id[:8]
466
+
467
+
468
+ def fetch_baseline(
469
+ loaded: LoadedSuite,
470
+ *,
471
+ endpoint: str,
472
+ api_key: str,
473
+ timeout: float = DEFAULT_TIMEOUT,
474
+ want_results: bool = False,
475
+ ) -> Baseline:
476
+ """Pull the baseline's metrics so regression gates can be applied locally.
477
+
478
+ Before the run, not after. A regression rule the local evaluation had to skip is a rule the
479
+ server then applies during `compare`, which produces a verdict difference that is legitimate and
480
+ indistinguishable from the kind that means something is broken. Fetching first collapses that
481
+ ambiguity: both sides see the same baseline and must agree.
482
+
483
+ Never raises. No baseline is the normal state of a new suite, and an unreachable server must
484
+ degrade to "no regression comparison" rather than stopping a run that can still gate on its
485
+ absolute floors.
486
+ """
487
+ if loaded.suite.baseline.strategy == "none":
488
+ return Baseline()
489
+ try:
490
+ with Publisher(endpoint, api_key, timeout=timeout) as publisher:
491
+ payload = publisher.fetch_baseline(
492
+ suite_name=loaded.suite.name, branch=loaded.suite.baseline.branch
493
+ )
494
+ except PublishError as exc:
495
+ return Baseline(error=str(exc))
496
+ except Exception as exc:
497
+ return Baseline(error=f"{type(exc).__name__}: {exc}")
498
+
499
+ if payload.get("run_id") is None:
500
+ return Baseline()
501
+
502
+ # A second call, and only when a rule actually needs it. Per-example results for a large suite
503
+ # are far bigger than its metrics, and fetching them for every run would make the common case
504
+ # (no significance rules) pay for a feature it does not use.
505
+ results: list[ExampleResult] = []
506
+ if want_results:
507
+ try:
508
+ with Publisher(endpoint, api_key, timeout=timeout) as publisher:
509
+ rows = publisher.get(
510
+ f"/v1/experiment-runs/{payload['run_id']}/results",
511
+ params={"limit": MAX_BASELINE_RESULTS},
512
+ )
513
+ results = [ExampleResult.model_validate(row) for row in rows or []]
514
+ except PublishError as exc:
515
+ # Degrades to a metrics-only baseline. The gate engine then reports the significance
516
+ # rule as undecidable rather than passing it, which is the honest outcome.
517
+ return Baseline(
518
+ run_id=str(payload["run_id"]),
519
+ git_commit=payload.get("git_commit"),
520
+ metrics=[Metric.model_validate(row) for row in payload.get("metrics") or []],
521
+ error=f"baseline results unavailable: {exc}",
522
+ )
523
+
524
+ return Baseline(
525
+ run_id=str(payload["run_id"]),
526
+ git_commit=payload.get("git_commit"),
527
+ metrics=[Metric.model_validate(row) for row in payload.get("metrics") or []],
528
+ results=results,
529
+ )
530
+
531
+
532
+ __all__ = [
533
+ "Baseline",
534
+ "PublishError",
535
+ "PublishOutcome",
536
+ "Publisher",
537
+ "fetch_baseline",
538
+ "publish",
539
+ "slugify",
540
+ "version_label",
541
+ ]
proofstep_cli/py.typed ADDED
File without changes