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/main.py
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""The `proofstep` command.
|
|
2
|
+
|
|
3
|
+
Exit codes are the contract with CI (docs/EVALUATION_ENGINE.md §7):
|
|
4
|
+
|
|
5
|
+
0 pass, or warn-only
|
|
6
|
+
1 a blocking gate failed
|
|
7
|
+
2 execution error — evaluators broke, or too many examples failed
|
|
8
|
+
3 configuration error — the suite itself is wrong
|
|
9
|
+
130 cancelled
|
|
10
|
+
|
|
11
|
+
Distinguishing 1 from 3 matters: "your change is worse" and "your suite is broken"
|
|
12
|
+
call for completely different responses, and collapsing them trains people to
|
|
13
|
+
ignore the exit code.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import platform
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Annotated, Any
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
|
|
28
|
+
from proofstep_cli import calibration, runner
|
|
29
|
+
from proofstep_cli import publish as publish_module
|
|
30
|
+
from proofstep_cli.render import calibration as calibration_render
|
|
31
|
+
from proofstep_cli.render import markdown as markdown_module
|
|
32
|
+
from proofstep_cli.render import report as report_module
|
|
33
|
+
from proofstep_cli.render import terminal
|
|
34
|
+
from proofstep_cli.suite.loader import SuiteError, load_suite
|
|
35
|
+
from proofstep_core.calibration import check_requirement
|
|
36
|
+
from proofstep_core.calibration_runner import report_to_dict
|
|
37
|
+
from proofstep_trajectory import PolicyError, evaluate_policy, load_policy_file
|
|
38
|
+
from proofstep_types import Trace
|
|
39
|
+
|
|
40
|
+
app = typer.Typer(
|
|
41
|
+
name="proofstep",
|
|
42
|
+
help="Evaluation CI and trajectory testing for AI agents.",
|
|
43
|
+
no_args_is_help=True,
|
|
44
|
+
add_completion=False,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _fail(message: str, code: int) -> None:
|
|
49
|
+
style = terminal.Style(colour=terminal.use_colour(sys.stderr), unicode_=terminal.use_unicode())
|
|
50
|
+
typer.echo(style.paint(f"{style.cross} {message}", terminal.RED), err=True)
|
|
51
|
+
raise typer.Exit(code)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_overrides(values: list[str] | None) -> dict[str, str]:
|
|
55
|
+
overrides: dict[str, str] = {}
|
|
56
|
+
for entry in values or []:
|
|
57
|
+
key, separator, value = entry.partition("=")
|
|
58
|
+
if not separator:
|
|
59
|
+
_fail(f"--set expects key=value, got {entry!r}", runner.exit_code_for_setup_error())
|
|
60
|
+
overrides[key.strip()] = value
|
|
61
|
+
return overrides
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command()
|
|
65
|
+
def eval( # noqa: PLR0917 — Typer maps CLI options onto arguments
|
|
66
|
+
suite_path: Annotated[Path, typer.Argument(help="Path to the suite YAML")],
|
|
67
|
+
local: Annotated[
|
|
68
|
+
bool,
|
|
69
|
+
typer.Option(
|
|
70
|
+
"--local/--publish",
|
|
71
|
+
help="Run offline, or record the run on the server (default: publish when configured)",
|
|
72
|
+
),
|
|
73
|
+
] = False,
|
|
74
|
+
require_publish: Annotated[
|
|
75
|
+
bool,
|
|
76
|
+
typer.Option(
|
|
77
|
+
"--require-publish",
|
|
78
|
+
help="Fail the command when the run could not be recorded on the server",
|
|
79
|
+
),
|
|
80
|
+
] = False,
|
|
81
|
+
dry_run: Annotated[
|
|
82
|
+
bool, typer.Option("--dry-run", help="Validate and plan without any model calls")
|
|
83
|
+
] = False,
|
|
84
|
+
output: Annotated[Path | None, typer.Option("--output", "-o", help="JSON report path")] = None,
|
|
85
|
+
limit: Annotated[
|
|
86
|
+
int | None, typer.Option("--limit", help="Only run the first N examples")
|
|
87
|
+
] = None,
|
|
88
|
+
set_: Annotated[
|
|
89
|
+
list[str] | None, typer.Option("--set", help="Override a suite field: a.b=value")
|
|
90
|
+
] = None,
|
|
91
|
+
journal: Annotated[
|
|
92
|
+
Path | None, typer.Option("--journal", help="Write a resumable run journal")
|
|
93
|
+
] = None,
|
|
94
|
+
resume: Annotated[
|
|
95
|
+
Path | None, typer.Option("--resume", help="Skip examples already in this journal")
|
|
96
|
+
] = None,
|
|
97
|
+
verbose: Annotated[
|
|
98
|
+
bool, typer.Option("--verbose", "-v", help="Show every sliced metric")
|
|
99
|
+
] = False,
|
|
100
|
+
model_client: Annotated[
|
|
101
|
+
str | None,
|
|
102
|
+
typer.Option("--model-client", help="module:factory returning a ModelClient, for judges"),
|
|
103
|
+
] = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Run a suite, apply its gates, and exit non-zero on a blocking failure.
|
|
106
|
+
|
|
107
|
+
A suite with LLM judges needs `--model-client` (or `PROOFSTEP_MODEL_CLIENT`). Provider SDKs
|
|
108
|
+
are deliberately absent from `evaluation-core` so local mode works with no dependencies, so
|
|
109
|
+
the model access is supplied by the project being evaluated.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
loaded = load_suite(suite_path, overrides=_parse_overrides(set_))
|
|
113
|
+
except SuiteError as exc:
|
|
114
|
+
_fail(str(exc), runner.exit_code_for_setup_error())
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
if dry_run:
|
|
118
|
+
_print_plan(loaded)
|
|
119
|
+
raise typer.Exit(0)
|
|
120
|
+
|
|
121
|
+
needs_models = any(spec.type == "llm_judge" for spec in loaded.suite.evaluators)
|
|
122
|
+
models = None
|
|
123
|
+
if needs_models:
|
|
124
|
+
try:
|
|
125
|
+
models = calibration.load_model_client(model_client)
|
|
126
|
+
except calibration.CalibrationCommandError as exc:
|
|
127
|
+
# Refused before running anything. A judge with no client returns an errored score
|
|
128
|
+
# on every example, which surfaces as "metric with no measurements" — a confusing
|
|
129
|
+
# way to say "you forgot to pass a model client".
|
|
130
|
+
_fail(str(exc), runner.exit_code_for_setup_error())
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
baseline = _fetch_baseline(loaded, local=local)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
outcome = asyncio.run(
|
|
137
|
+
runner.execute(
|
|
138
|
+
loaded,
|
|
139
|
+
models=models,
|
|
140
|
+
baseline_metrics=baseline.metrics or None,
|
|
141
|
+
baseline_results=baseline.results or None,
|
|
142
|
+
journal=journal,
|
|
143
|
+
resume=resume,
|
|
144
|
+
limit=limit,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
except runner.RunError as exc:
|
|
148
|
+
_fail(str(exc), runner.exit_code_for_setup_error())
|
|
149
|
+
return
|
|
150
|
+
except KeyboardInterrupt:
|
|
151
|
+
_fail("cancelled", 130)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
if baseline.label:
|
|
155
|
+
outcome.baseline_label = baseline.label
|
|
156
|
+
published = _publish(loaded, outcome, local=local)
|
|
157
|
+
|
|
158
|
+
report_path = str(output or loaded.suite.report.output)
|
|
159
|
+
git = runner.git_context()
|
|
160
|
+
payload = report_module.build_report(
|
|
161
|
+
outcome.result,
|
|
162
|
+
comparison=outcome.comparison,
|
|
163
|
+
git_commit=git[0],
|
|
164
|
+
git_branch=git[1],
|
|
165
|
+
baseline_run_id=published.baseline_run_id,
|
|
166
|
+
experiment_url=published.experiment_url,
|
|
167
|
+
hints=loaded.hints,
|
|
168
|
+
)
|
|
169
|
+
if "json" in loaded.suite.report.formats:
|
|
170
|
+
report_module.write_report(payload, report_path)
|
|
171
|
+
|
|
172
|
+
if "terminal" in loaded.suite.report.formats:
|
|
173
|
+
typer.echo(
|
|
174
|
+
terminal.render(
|
|
175
|
+
outcome.result,
|
|
176
|
+
comparison=outcome.comparison,
|
|
177
|
+
hints=loaded.hints,
|
|
178
|
+
report_path=report_path if "json" in loaded.suite.report.formats else None,
|
|
179
|
+
baseline_label=outcome.baseline_label,
|
|
180
|
+
verbose=verbose,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
_report_publish(published)
|
|
185
|
+
|
|
186
|
+
# The exit code is the local verdict's, always. A server that is slow, unreachable, or
|
|
187
|
+
# misconfigured must not be able to turn a failing run into a passing one — the gate would then
|
|
188
|
+
# depend on infrastructure rather than on the code being merged. `--require-publish` is the
|
|
189
|
+
# opt-in for teams whose process needs the record to exist, and even it can only make a passing
|
|
190
|
+
# run fail, never the reverse.
|
|
191
|
+
exit_code = outcome.exit_code
|
|
192
|
+
if require_publish and not published.published and not published.skipped_reason:
|
|
193
|
+
exit_code = max(exit_code, runner.exit_code_for_setup_error())
|
|
194
|
+
elif require_publish and published.skipped_reason:
|
|
195
|
+
_fail(
|
|
196
|
+
f"--require-publish was given but publishing was skipped: {published.skipped_reason}",
|
|
197
|
+
runner.exit_code_for_setup_error(),
|
|
198
|
+
)
|
|
199
|
+
raise typer.Exit(exit_code)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _fetch_baseline(loaded: Any, *, local: bool) -> publish_module.Baseline:
|
|
203
|
+
"""Pull the baseline's metrics before running, when a server is configured.
|
|
204
|
+
|
|
205
|
+
Doing this *before* the run rather than comparing afterwards is what lets regression gates fire
|
|
206
|
+
in the same process that produces the exit code — and it keeps the local and server verdicts
|
|
207
|
+
computed against the same baseline, so a difference between them means a bug rather than a
|
|
208
|
+
difference in what each side knew.
|
|
209
|
+
"""
|
|
210
|
+
endpoint = os.environ.get("PROOFSTEP_ENDPOINT")
|
|
211
|
+
api_key = os.environ.get("PROOFSTEP_API_KEY")
|
|
212
|
+
if local or not endpoint or not api_key:
|
|
213
|
+
return publish_module.Baseline()
|
|
214
|
+
|
|
215
|
+
# Only when a gate actually asks for a test. Otherwise the run pays to download every example's
|
|
216
|
+
# scores for a feature it does not use.
|
|
217
|
+
from proofstep_cli.runner import build_gate_set # noqa: PLC0415 — avoids a cycle
|
|
218
|
+
|
|
219
|
+
gate_set = build_gate_set(loaded)
|
|
220
|
+
wants_pairs = bool(gate_set and any(rule.needs_significance for rule in gate_set.rules))
|
|
221
|
+
|
|
222
|
+
baseline = publish_module.fetch_baseline(
|
|
223
|
+
loaded, endpoint=endpoint, api_key=api_key, want_results=wants_pairs
|
|
224
|
+
)
|
|
225
|
+
if baseline.error:
|
|
226
|
+
# A warning, not a failure. Absolute floors still gate correctly with no baseline; only
|
|
227
|
+
# regression rules are skipped, and `require_baseline` on a rule is how a suite declares
|
|
228
|
+
# that skipping is unacceptable for it.
|
|
229
|
+
style = terminal.Style(colour=terminal.use_colour(), unicode_=terminal.use_unicode())
|
|
230
|
+
typer.echo(
|
|
231
|
+
style.paint(
|
|
232
|
+
f"{style.warn} no baseline · {baseline.error}",
|
|
233
|
+
terminal.YELLOW,
|
|
234
|
+
),
|
|
235
|
+
err=True,
|
|
236
|
+
)
|
|
237
|
+
return baseline
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _publish(loaded: Any, outcome: runner.Outcome, *, local: bool) -> publish_module.PublishOutcome:
|
|
241
|
+
"""Record the run, unless the caller asked not to or the environment cannot.
|
|
242
|
+
|
|
243
|
+
Publishing is *opt-out but conditional*: it happens when a server is configured and is skipped
|
|
244
|
+
quietly when one is not, so `proofstep eval` on a laptop with no account behaves exactly as it
|
|
245
|
+
always has. Requiring a flag to get the record would mean most CI jobs never produce one.
|
|
246
|
+
"""
|
|
247
|
+
if local:
|
|
248
|
+
return publish_module.PublishOutcome(skipped_reason="--local was given")
|
|
249
|
+
|
|
250
|
+
endpoint = os.environ.get("PROOFSTEP_ENDPOINT")
|
|
251
|
+
api_key = os.environ.get("PROOFSTEP_API_KEY")
|
|
252
|
+
if not endpoint or not api_key:
|
|
253
|
+
return publish_module.PublishOutcome(
|
|
254
|
+
skipped_reason="PROOFSTEP_ENDPOINT and PROOFSTEP_API_KEY are not both set"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
dataset = runner.load_dataset(loaded)
|
|
259
|
+
except (runner.RunError, SuiteError) as exc:
|
|
260
|
+
return publish_module.PublishOutcome(
|
|
261
|
+
error=f"could not reload the dataset to publish: {exc}"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return publish_module.publish(
|
|
265
|
+
loaded,
|
|
266
|
+
outcome.result,
|
|
267
|
+
dataset,
|
|
268
|
+
endpoint=endpoint,
|
|
269
|
+
api_key=api_key,
|
|
270
|
+
git=runner.git_context(),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _report_publish(published: publish_module.PublishOutcome) -> None:
|
|
275
|
+
"""Say what happened, in one line — including when nothing did.
|
|
276
|
+
|
|
277
|
+
A silent skip is the failure mode worth designing against: someone believes the record exists,
|
|
278
|
+
and nobody goes looking for a thing they are sure is there.
|
|
279
|
+
"""
|
|
280
|
+
style = terminal.Style(colour=terminal.use_colour(), unicode_=terminal.use_unicode())
|
|
281
|
+
|
|
282
|
+
if published.skipped_reason:
|
|
283
|
+
typer.echo(style.paint(f"not published · {published.skipped_reason}", terminal.DIM))
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
if published.error:
|
|
287
|
+
typer.echo(
|
|
288
|
+
style.paint(f"{style.warn} not published · {published.error}", terminal.YELLOW),
|
|
289
|
+
err=True,
|
|
290
|
+
)
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
typer.echo(style.paint(f"published · {published.experiment_url}", terminal.DIM))
|
|
294
|
+
|
|
295
|
+
for divergence in published.divergences:
|
|
296
|
+
# Loud, and on stderr. The two verdicts are computed by the same code from the same
|
|
297
|
+
# numbers, so a difference is a bug in this system rather than a fact about the run — and
|
|
298
|
+
# it means the exit code CI just acted on and the dashboard's verdict disagree.
|
|
299
|
+
typer.echo(
|
|
300
|
+
style.paint(f"{style.warn} server disagreed · {divergence}", terminal.YELLOW), err=True
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _print_plan(loaded: Any) -> None:
|
|
305
|
+
"""What the run would do, and roughly what it would cost.
|
|
306
|
+
|
|
307
|
+
A suite can be expensive; being able to check the wiring for free is the point.
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
plan = runner.plan_run(loaded)
|
|
311
|
+
except (runner.RunError, SuiteError) as exc:
|
|
312
|
+
_fail(str(exc), runner.exit_code_for_setup_error())
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
style = terminal.Style(colour=terminal.use_colour(), unicode_=terminal.use_unicode())
|
|
316
|
+
typer.echo(style.paint(f"Proofstep · {plan.suite} (dry run)", terminal.BOLD))
|
|
317
|
+
typer.echo(f" dataset {plan.dataset} ({plan.example_count} examples)")
|
|
318
|
+
typer.echo(f" evaluators {', '.join(plan.evaluator_names) or 'none'}")
|
|
319
|
+
if plan.corpus_names:
|
|
320
|
+
typer.echo(f" corpus metrics {', '.join(plan.corpus_names)}")
|
|
321
|
+
typer.echo(f" gates {plan.gate_count}")
|
|
322
|
+
typer.echo(f" baseline {plan.baseline}")
|
|
323
|
+
typer.echo(
|
|
324
|
+
f" judge calls {plan.judge_calls}"
|
|
325
|
+
+ (" (no model calls were made)" if plan.judge_calls else "")
|
|
326
|
+
)
|
|
327
|
+
for hint in plan.hints:
|
|
328
|
+
typer.echo(style.paint(f" {style.warn} {hint}", terminal.YELLOW))
|
|
329
|
+
typer.echo(style.paint("\nno model calls were made", terminal.DIM))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@app.command()
|
|
333
|
+
def validate(
|
|
334
|
+
suite_path: Annotated[Path, typer.Argument(help="Path to the suite YAML")],
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Check a suite without running it."""
|
|
337
|
+
try:
|
|
338
|
+
loaded = load_suite(suite_path)
|
|
339
|
+
except SuiteError as exc:
|
|
340
|
+
_fail(str(exc), runner.exit_code_for_setup_error())
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
style = terminal.Style(colour=terminal.use_colour(), unicode_=terminal.use_unicode())
|
|
344
|
+
typer.echo(style.paint(f"{style.tick} {loaded.path.name} is valid", terminal.GREEN))
|
|
345
|
+
typer.echo(f" {len(loaded.suite.evaluators)} evaluator(s), {len(loaded.suite.gates)} gate(s)")
|
|
346
|
+
for hint in loaded.hints:
|
|
347
|
+
typer.echo(style.paint(f" {style.warn} {hint}", terminal.YELLOW))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.command()
|
|
351
|
+
def comment(
|
|
352
|
+
report_path: Annotated[Path, typer.Argument(help="A report JSON produced by `eval`")],
|
|
353
|
+
run_url: Annotated[
|
|
354
|
+
str | None, typer.Option("--run-url", help="Link back to the CI run")
|
|
355
|
+
] = None,
|
|
356
|
+
output: Annotated[
|
|
357
|
+
Path | None, typer.Option("--output", "-o", help="Write markdown here instead of stdout")
|
|
358
|
+
] = None,
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Render a report as pull-request markdown.
|
|
361
|
+
|
|
362
|
+
Deliberately knows nothing about GitHub: it reads a file and writes markdown, so
|
|
363
|
+
it is a pure function that can be snapshot-tested, and any CI system can post
|
|
364
|
+
the result however it likes.
|
|
365
|
+
"""
|
|
366
|
+
if not report_path.exists():
|
|
367
|
+
_fail(f"report not found: {report_path}", runner.exit_code_for_setup_error())
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
payload = json.loads(report_path.read_text(encoding="utf-8"))
|
|
372
|
+
except json.JSONDecodeError as exc:
|
|
373
|
+
_fail(f"{report_path}: not valid JSON: {exc}", runner.exit_code_for_setup_error())
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
body = markdown_module.render(payload, run_url=run_url)
|
|
377
|
+
if output:
|
|
378
|
+
output.write_text(body, encoding="utf-8")
|
|
379
|
+
typer.echo(f"wrote {output}")
|
|
380
|
+
else:
|
|
381
|
+
typer.echo(body)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@app.command(name="comment-error")
|
|
385
|
+
def comment_error(
|
|
386
|
+
message: Annotated[str, typer.Argument(help="What went wrong")],
|
|
387
|
+
suite: Annotated[str | None, typer.Option("--suite")] = None,
|
|
388
|
+
run_url: Annotated[str | None, typer.Option("--run-url")] = None,
|
|
389
|
+
output: Annotated[Path | None, typer.Option("--output", "-o")] = None,
|
|
390
|
+
) -> None:
|
|
391
|
+
"""Render a comment for a run that never produced a report.
|
|
392
|
+
|
|
393
|
+
Posting something matters: an absent comment reads as "no problems found",
|
|
394
|
+
which is the opposite of what happened.
|
|
395
|
+
"""
|
|
396
|
+
body = markdown_module.render_error(message, suite=suite, run_url=run_url)
|
|
397
|
+
if output:
|
|
398
|
+
output.write_text(body, encoding="utf-8")
|
|
399
|
+
typer.echo(f"wrote {output}")
|
|
400
|
+
else:
|
|
401
|
+
typer.echo(body)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@app.command()
|
|
405
|
+
def doctor() -> None:
|
|
406
|
+
"""Report the environment Proofstep sees, for debugging a broken setup."""
|
|
407
|
+
style = terminal.Style(colour=terminal.use_colour(), unicode_=terminal.use_unicode())
|
|
408
|
+
commit, branch, dirty = runner.git_context()
|
|
409
|
+
|
|
410
|
+
typer.echo(style.paint("Proofstep doctor", terminal.BOLD))
|
|
411
|
+
typer.echo(f" python {platform.python_version()} ({sys.executable})")
|
|
412
|
+
typer.echo(f" cwd {Path.cwd()}")
|
|
413
|
+
typer.echo(
|
|
414
|
+
f" git {branch or '—'} @ {(commit or '—')[:8]}{' (dirty)' if dirty else ''}"
|
|
415
|
+
)
|
|
416
|
+
typer.echo(f" endpoint {os.environ.get('PROOFSTEP_ENDPOINT', '<unset>')}")
|
|
417
|
+
# Presence only, never the value. A diagnostic command that prints a credential
|
|
418
|
+
# is a diagnostic command that leaks one into a bug report.
|
|
419
|
+
typer.echo(f" api key {'set' if os.environ.get('PROOFSTEP_API_KEY') else 'unset'}")
|
|
420
|
+
typer.echo(f" colour output {terminal.use_colour()}")
|
|
421
|
+
typer.echo(f" unicode output {terminal.use_unicode()}")
|
|
422
|
+
|
|
423
|
+
for package in ("proofstep_types", "proofstep_core", "proofstep_trajectory", "proofstep"):
|
|
424
|
+
try:
|
|
425
|
+
module = __import__(package)
|
|
426
|
+
except ImportError:
|
|
427
|
+
typer.echo(style.paint(f" {style.cross} {package} not importable", terminal.RED))
|
|
428
|
+
else:
|
|
429
|
+
typer.echo(f" {package:<20} {getattr(module, '__version__', 'unknown')}")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@app.command(name="policy-check")
|
|
433
|
+
def policy_check(
|
|
434
|
+
policy_path: Annotated[Path, typer.Argument(help="Policy YAML")],
|
|
435
|
+
trace_path: Annotated[
|
|
436
|
+
Path | None, typer.Argument(help="A trace JSON file to evaluate against")
|
|
437
|
+
] = None,
|
|
438
|
+
) -> None:
|
|
439
|
+
"""Validate a trajectory policy, and optionally evaluate it against a trace."""
|
|
440
|
+
style = terminal.Style(colour=terminal.use_colour(), unicode_=terminal.use_unicode())
|
|
441
|
+
try:
|
|
442
|
+
loaded = load_policy_file(policy_path)
|
|
443
|
+
except PolicyError as exc:
|
|
444
|
+
_fail(str(exc), runner.exit_code_for_setup_error())
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
typer.echo(
|
|
448
|
+
style.paint(
|
|
449
|
+
f"{style.tick} {policy_path.name}: {len(loaded.policy.rules)} rule(s)", terminal.GREEN
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
if trace_path is None:
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
trace = Trace.model_validate_json(trace_path.read_text(encoding="utf-8"))
|
|
456
|
+
result = evaluate_policy(loaded, trace)
|
|
457
|
+
typer.echo(result.format(policy_path=str(policy_path)))
|
|
458
|
+
raise typer.Exit(0 if result.passed else 1)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
if __name__ == "__main__": # pragma: no cover
|
|
462
|
+
app()
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@app.command()
|
|
466
|
+
def calibrate( # noqa: PLR0917 — Typer maps CLI options onto arguments
|
|
467
|
+
suite_path: Annotated[Path, typer.Argument(help="Path to the suite YAML")],
|
|
468
|
+
evaluator: Annotated[
|
|
469
|
+
str, typer.Option("--evaluator", "-e", help="Name of the llm_judge to calibrate")
|
|
470
|
+
],
|
|
471
|
+
labels: Annotated[
|
|
472
|
+
Path | None,
|
|
473
|
+
typer.Option(
|
|
474
|
+
"--labels", help="Labelled JSONL; defaults to the judge's calibration.dataset"
|
|
475
|
+
),
|
|
476
|
+
] = None,
|
|
477
|
+
verdicts: Annotated[
|
|
478
|
+
Path | None,
|
|
479
|
+
typer.Option(
|
|
480
|
+
"--verdicts",
|
|
481
|
+
help="Recompute from recorded judge verdicts instead of calling the model",
|
|
482
|
+
),
|
|
483
|
+
] = None,
|
|
484
|
+
model_client: Annotated[
|
|
485
|
+
str | None,
|
|
486
|
+
typer.Option("--model-client", help="module:factory returning a ModelClient"),
|
|
487
|
+
] = None,
|
|
488
|
+
concurrency: Annotated[int, typer.Option("--concurrency", help="Parallel judge calls")] = 4,
|
|
489
|
+
dry_run: Annotated[
|
|
490
|
+
bool, typer.Option("--dry-run", help="Report the plan and cost without calling the model")
|
|
491
|
+
] = False,
|
|
492
|
+
write: Annotated[
|
|
493
|
+
bool, typer.Option("--write/--no-write", help="Store the record for CI to read")
|
|
494
|
+
] = True,
|
|
495
|
+
output: Annotated[
|
|
496
|
+
Path | None, typer.Option("--output", "-o", help="Also write the raw report JSON here")
|
|
497
|
+
] = None,
|
|
498
|
+
) -> None:
|
|
499
|
+
"""Measure a judge against human labels and record the result.
|
|
500
|
+
|
|
501
|
+
Exit 0 when the judge meets its requirement, 1 when it does not. Non-zero is
|
|
502
|
+
deliberate: calibration belongs in CI, and "the judge got worse" should be able to
|
|
503
|
+
fail a build the same way a metric regression does.
|
|
504
|
+
"""
|
|
505
|
+
try:
|
|
506
|
+
loaded = load_suite(suite_path)
|
|
507
|
+
plan = calibration.plan(loaded, evaluator=evaluator, labels=labels)
|
|
508
|
+
except (SuiteError, calibration.CalibrationCommandError) as exc:
|
|
509
|
+
_fail(str(exc), runner.exit_code_for_setup_error())
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
style = terminal.Style(colour=terminal.use_colour(), unicode_=terminal.use_unicode())
|
|
513
|
+
typer.echo(style.paint(f"Proofstep · calibrate {evaluator}", terminal.BOLD))
|
|
514
|
+
typer.echo(f" labelled set {plan.labels_path} ({len(plan.cases)} examples)")
|
|
515
|
+
typer.echo(f" label counts {plan.label_summary}")
|
|
516
|
+
typer.echo(f" judge version {plan.version_hash}")
|
|
517
|
+
typer.echo(f" judge calls {plan.judge_calls}")
|
|
518
|
+
|
|
519
|
+
if dry_run:
|
|
520
|
+
typer.echo(style.paint("\nno model calls were made", terminal.DIM))
|
|
521
|
+
raise typer.Exit(0)
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
report = calibration.produce(
|
|
525
|
+
plan, verdicts_path=verdicts, model_client=model_client, concurrency=concurrency
|
|
526
|
+
)
|
|
527
|
+
except calibration.CalibrationCommandError as exc:
|
|
528
|
+
_fail(str(exc), runner.exit_code_for_setup_error())
|
|
529
|
+
return
|
|
530
|
+
except KeyboardInterrupt:
|
|
531
|
+
_fail("cancelled", 130)
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
check = check_requirement(report, plan.requirement)
|
|
535
|
+
typer.echo("")
|
|
536
|
+
typer.echo(
|
|
537
|
+
calibration_render.render(
|
|
538
|
+
report,
|
|
539
|
+
check,
|
|
540
|
+
evaluator=evaluator,
|
|
541
|
+
version_hash=plan.version_hash,
|
|
542
|
+
style=style,
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
payload = report_to_dict(report)
|
|
547
|
+
if output:
|
|
548
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
549
|
+
output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
550
|
+
|
|
551
|
+
if write:
|
|
552
|
+
stored = calibration.store(plan, report, check)
|
|
553
|
+
typer.echo(f"\nrecorded {stored}")
|
|
554
|
+
typer.echo(
|
|
555
|
+
style.paint(
|
|
556
|
+
"commit this file: it is the evidence CI reads to decide whether the "
|
|
557
|
+
"judge can be trusted",
|
|
558
|
+
terminal.DIM,
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
raise typer.Exit(0 if check.satisfied else 1)
|