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/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)