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.
- proofstep_cli/__init__.py +8 -0
- proofstep_cli/calibration.py +318 -0
- proofstep_cli/calibration_store.py +215 -0
- proofstep_cli/commands/__init__.py +1 -0
- proofstep_cli/main.py +562 -0
- proofstep_cli/publish.py +541 -0
- proofstep_cli/py.typed +0 -0
- proofstep_cli/registry.py +234 -0
- proofstep_cli/render/__init__.py +1 -0
- proofstep_cli/render/calibration.py +127 -0
- proofstep_cli/render/markdown.py +304 -0
- proofstep_cli/render/report.py +193 -0
- proofstep_cli/render/terminal.py +287 -0
- proofstep_cli/runner.py +374 -0
- proofstep_cli/suite/__init__.py +1 -0
- proofstep_cli/suite/loader.py +377 -0
- proofstep_cli/suite/schema.py +343 -0
- proofstep_cli-0.1.0.dist-info/METADATA +58 -0
- proofstep_cli-0.1.0.dist-info/RECORD +21 -0
- proofstep_cli-0.1.0.dist-info/WHEEL +4 -0
- proofstep_cli-0.1.0.dist-info/entry_points.txt +2 -0
proofstep_cli/publish.py
ADDED
|
@@ -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
|