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/runner.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Orchestrate a suite: load, resolve, execute, aggregate, gate, report.
|
|
2
|
+
|
|
3
|
+
`--local` is the default path and needs no server, no account, and no network. That
|
|
4
|
+
is deliberate (ADR-017): the tool has to be useful before anyone signs up, and it
|
|
5
|
+
makes the whole pipeline testable without infrastructure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from proofstep_cli.calibration_store import (
|
|
19
|
+
StoredCalibration,
|
|
20
|
+
evaluator_version_hash,
|
|
21
|
+
load_all,
|
|
22
|
+
status_for,
|
|
23
|
+
)
|
|
24
|
+
from proofstep_cli.registry import build_evaluators, estimate_judge_calls, load_rubric_text
|
|
25
|
+
from proofstep_cli.suite.loader import LoadedSuite, SuiteError
|
|
26
|
+
from proofstep_core import Dataset, EvalResult, RunConfig, run_suite
|
|
27
|
+
from proofstep_core.calibration import CalibrationRequirement, check_requirement
|
|
28
|
+
from proofstep_core.calibration_runner import report_from_dict
|
|
29
|
+
from proofstep_core.compare import compare_metrics
|
|
30
|
+
from proofstep_types import (
|
|
31
|
+
CalibrationRequirementSpec,
|
|
32
|
+
CalibrationStatus,
|
|
33
|
+
ExampleResult,
|
|
34
|
+
ExitCode,
|
|
35
|
+
GateRule,
|
|
36
|
+
GateSet,
|
|
37
|
+
Metric,
|
|
38
|
+
Severity,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from collections.abc import Callable
|
|
43
|
+
|
|
44
|
+
from proofstep_core.compare import Comparison
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RunError(RuntimeError):
|
|
48
|
+
"""The run could not be set up. Distinct from a run that produced a failure."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Plan:
|
|
53
|
+
"""What a run *would* do, for `--dry-run`."""
|
|
54
|
+
|
|
55
|
+
suite: str
|
|
56
|
+
dataset: str
|
|
57
|
+
example_count: int
|
|
58
|
+
evaluator_names: list[str] = field(default_factory=list)
|
|
59
|
+
corpus_names: list[str] = field(default_factory=list)
|
|
60
|
+
gate_count: int = 0
|
|
61
|
+
judge_calls: int = 0
|
|
62
|
+
baseline: str | None = None
|
|
63
|
+
hints: list[str] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Outcome:
|
|
68
|
+
result: EvalResult
|
|
69
|
+
comparison: Comparison | None = None
|
|
70
|
+
baseline_label: str | None = None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def exit_code(self) -> int:
|
|
74
|
+
return self.result.exit_code
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def git_context() -> tuple[str | None, str | None, bool]:
|
|
78
|
+
"""Best-effort commit, branch, and dirty flag.
|
|
79
|
+
|
|
80
|
+
Recorded because an experiment nobody can tie to a commit is an anecdote. Failure
|
|
81
|
+
is tolerated: plenty of valid environments have no git.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def run(args: list[str]) -> str | None:
|
|
85
|
+
try:
|
|
86
|
+
output = subprocess.run( # noqa: S603 — fixed argv, no shell
|
|
87
|
+
["git", *args], # noqa: S607 — git is intentionally taken from PATH
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
timeout=5,
|
|
91
|
+
check=False,
|
|
92
|
+
)
|
|
93
|
+
except (OSError, subprocess.SubprocessError):
|
|
94
|
+
return None
|
|
95
|
+
return output.stdout.strip() or None if output.returncode == 0 else None
|
|
96
|
+
|
|
97
|
+
commit = run(["rev-parse", "HEAD"])
|
|
98
|
+
branch = run(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
99
|
+
dirty = bool(run(["status", "--porcelain"]))
|
|
100
|
+
return commit, branch, dirty
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_task(entrypoint: str) -> Callable[..., Any]:
|
|
104
|
+
"""Import `module:function`.
|
|
105
|
+
|
|
106
|
+
Errors name both halves. A bare "ModuleNotFoundError: mypkg" leaves the reader
|
|
107
|
+
guessing which suite field produced it.
|
|
108
|
+
"""
|
|
109
|
+
module_name, _, attribute = entrypoint.partition(":")
|
|
110
|
+
|
|
111
|
+
# The CLI exists to run the user's project code, and it is normally invoked from
|
|
112
|
+
# that project's root. Python does not put the working directory on the path for
|
|
113
|
+
# an installed console script, so without this every suite would need PYTHONPATH
|
|
114
|
+
# set — a papercut with no upside.
|
|
115
|
+
cwd = str(Path.cwd())
|
|
116
|
+
if cwd not in sys.path:
|
|
117
|
+
sys.path.insert(0, cwd)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
module = importlib.import_module(module_name)
|
|
121
|
+
except ImportError as exc:
|
|
122
|
+
msg = (
|
|
123
|
+
f"cannot import module {module_name!r} from task entrypoint {entrypoint!r}: {exc}. "
|
|
124
|
+
"Is it importable from the current working directory?"
|
|
125
|
+
)
|
|
126
|
+
raise RunError(msg) from exc
|
|
127
|
+
|
|
128
|
+
target: Any = module
|
|
129
|
+
for part in attribute.split("."):
|
|
130
|
+
if not hasattr(target, part):
|
|
131
|
+
msg = f"module {module_name!r} has no attribute {attribute!r}"
|
|
132
|
+
raise RunError(msg)
|
|
133
|
+
target = getattr(target, part)
|
|
134
|
+
|
|
135
|
+
if not callable(target):
|
|
136
|
+
msg = f"task entrypoint {entrypoint!r} is not callable"
|
|
137
|
+
raise RunError(msg)
|
|
138
|
+
return target # type: ignore[no-any-return]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_dataset(loaded: LoadedSuite) -> Dataset:
|
|
142
|
+
reference = loaded.suite.dataset
|
|
143
|
+
if not reference.is_local:
|
|
144
|
+
msg = (
|
|
145
|
+
f"dataset {reference.name!r} lives on the server; run without --local, or point "
|
|
146
|
+
"the suite at a local `path:` for offline use"
|
|
147
|
+
)
|
|
148
|
+
raise RunError(msg)
|
|
149
|
+
|
|
150
|
+
assert reference.path is not None
|
|
151
|
+
path = loaded.resolve_path(reference.path)
|
|
152
|
+
dataset = Dataset.from_csv(path) if path.suffix.lower() == ".csv" else Dataset.from_jsonl(path)
|
|
153
|
+
if reference.limit:
|
|
154
|
+
dataset = dataset.limit(reference.limit)
|
|
155
|
+
return dataset
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_gate_set(loaded: LoadedSuite) -> GateSet | None:
|
|
159
|
+
suite = loaded.suite
|
|
160
|
+
if not suite.gates:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
rules: list[GateRule] = []
|
|
164
|
+
for metric_key, spec in suite.gates.items():
|
|
165
|
+
rules.append(
|
|
166
|
+
GateRule(
|
|
167
|
+
metric_key=metric_key.split("[")[0],
|
|
168
|
+
minimum=spec.minimum,
|
|
169
|
+
maximum=spec.maximum,
|
|
170
|
+
max_absolute_regression=spec.max_regression,
|
|
171
|
+
max_relative_regression=spec.max_relative_regression,
|
|
172
|
+
severity=Severity.BLOCK if spec.blocking else Severity.WARN,
|
|
173
|
+
slice=spec.slice,
|
|
174
|
+
require_baseline=spec.require_baseline,
|
|
175
|
+
significance=spec.significance,
|
|
176
|
+
require_power=spec.require_power,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
return GateSet(
|
|
180
|
+
name=suite.name,
|
|
181
|
+
rules=rules,
|
|
182
|
+
require_dataset_match=suite.baseline.require_dataset_match,
|
|
183
|
+
# The mapping form is validated here rather than in the suite schema, so a bad
|
|
184
|
+
# threshold names the gate field it came from instead of a pydantic union error.
|
|
185
|
+
require_calibration=(
|
|
186
|
+
suite.calibration.require
|
|
187
|
+
if isinstance(suite.calibration.require, bool)
|
|
188
|
+
else CalibrationRequirementSpec(**suite.calibration.require)
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def judge_metric_keys(loaded: LoadedSuite) -> list[str]:
|
|
194
|
+
"""Metric keys produced by LLM judges.
|
|
195
|
+
|
|
196
|
+
The gate engine cannot work this out for itself — a `Metric` is a key and a number —
|
|
197
|
+
and it needs to know, because gating on a judge nobody has checked is the specific
|
|
198
|
+
thing calibration exists to make visible.
|
|
199
|
+
"""
|
|
200
|
+
return [spec.name for spec in loaded.suite.evaluators if spec.type == "llm_judge"]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def resolve_calibrations(loaded: LoadedSuite) -> dict[str, CalibrationStatus]:
|
|
204
|
+
"""Load stored calibration evidence and re-check it against the current thresholds.
|
|
205
|
+
|
|
206
|
+
Re-checked, not trusted. The stored record carries the `satisfied` verdict from when
|
|
207
|
+
it was produced, but the suite's thresholds may have been tightened since. Reading
|
|
208
|
+
the old boolean would make `min_kappa` decorative until somebody remembered to
|
|
209
|
+
re-run a paid calibration.
|
|
210
|
+
"""
|
|
211
|
+
suite = loaded.suite
|
|
212
|
+
directory = loaded.resolve_path(suite.calibration.directory)
|
|
213
|
+
records = load_all(directory)
|
|
214
|
+
|
|
215
|
+
statuses: dict[str, CalibrationStatus] = {}
|
|
216
|
+
for spec in suite.evaluators:
|
|
217
|
+
if spec.type != "llm_judge":
|
|
218
|
+
continue
|
|
219
|
+
version = evaluator_version_hash(spec, rubric=load_rubric_text(spec, loaded))
|
|
220
|
+
status = status_for(
|
|
221
|
+
records, evaluator=spec.name, metric_key=spec.name, version_hash=version
|
|
222
|
+
)
|
|
223
|
+
if status.calibrated and not status.is_stale:
|
|
224
|
+
status = _recheck(status, spec, records)
|
|
225
|
+
statuses[spec.name] = status
|
|
226
|
+
return statuses
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _recheck(
|
|
230
|
+
status: CalibrationStatus, spec: Any, records: list[StoredCalibration]
|
|
231
|
+
) -> CalibrationStatus:
|
|
232
|
+
record = next(
|
|
233
|
+
r
|
|
234
|
+
for r in records
|
|
235
|
+
if r.evaluator == spec.name and r.version_hash == status.evaluator_version_hash
|
|
236
|
+
)
|
|
237
|
+
report = report_from_dict(record.report)
|
|
238
|
+
check = check_requirement(report, requirement_for(spec))
|
|
239
|
+
return status.model_copy(
|
|
240
|
+
update={
|
|
241
|
+
"satisfied": check.satisfied,
|
|
242
|
+
"failures": list(check.failures),
|
|
243
|
+
"warnings": list(check.warnings),
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def requirement_for(spec: Any) -> CalibrationRequirement:
|
|
249
|
+
"""The thresholds one judge must meet, defaults filled in.
|
|
250
|
+
|
|
251
|
+
`None` in the suite means "use the recommended default", not "no limit". A suite that
|
|
252
|
+
omits `max_false_pass_rate` should still be protected against a judge that waves
|
|
253
|
+
through work a human rejected.
|
|
254
|
+
"""
|
|
255
|
+
default = CalibrationRequirement()
|
|
256
|
+
calibration = spec.calibration
|
|
257
|
+
if calibration is None:
|
|
258
|
+
return default
|
|
259
|
+
return CalibrationRequirement(
|
|
260
|
+
min_agreement=_or(calibration.min_agreement, default.min_agreement),
|
|
261
|
+
min_kappa=_or(calibration.min_kappa, default.min_kappa),
|
|
262
|
+
max_false_pass_rate=_or(calibration.max_false_pass_rate, default.max_false_pass_rate),
|
|
263
|
+
max_false_fail_rate=_or(calibration.max_false_fail_rate, default.max_false_fail_rate),
|
|
264
|
+
min_examples=_or(calibration.min_examples, default.min_examples),
|
|
265
|
+
min_per_class=_or(calibration.min_per_class, default.min_per_class),
|
|
266
|
+
allow_position_bias=calibration.allow_position_bias,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _or(value: Any, fallback: Any) -> Any:
|
|
271
|
+
return fallback if value is None else value
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def plan_run(loaded: LoadedSuite) -> Plan:
|
|
275
|
+
"""Everything a run needs, resolved and checked, with zero model calls."""
|
|
276
|
+
dataset = load_dataset(loaded)
|
|
277
|
+
per_example, corpus = build_evaluators(loaded)
|
|
278
|
+
|
|
279
|
+
return Plan(
|
|
280
|
+
suite=loaded.suite.name,
|
|
281
|
+
dataset=str(loaded.suite.dataset.path or loaded.suite.dataset.name),
|
|
282
|
+
example_count=len(dataset),
|
|
283
|
+
evaluator_names=[e.name for e in per_example],
|
|
284
|
+
corpus_names=[c.name for c in corpus],
|
|
285
|
+
gate_count=len(loaded.suite.gates),
|
|
286
|
+
judge_calls=estimate_judge_calls(loaded, len(dataset)),
|
|
287
|
+
baseline=loaded.suite.baseline.strategy,
|
|
288
|
+
hints=loaded.hints,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def execute(
|
|
293
|
+
loaded: LoadedSuite,
|
|
294
|
+
*,
|
|
295
|
+
models: Any = None,
|
|
296
|
+
baseline_metrics: list[Metric] | None = None,
|
|
297
|
+
baseline_results: list[ExampleResult] | None = None,
|
|
298
|
+
journal: Path | None = None,
|
|
299
|
+
resume: Path | None = None,
|
|
300
|
+
limit: int | None = None,
|
|
301
|
+
) -> Outcome:
|
|
302
|
+
suite = loaded.suite
|
|
303
|
+
if suite.task is None:
|
|
304
|
+
msg = f"suite {suite.name!r} declares no `task`, so there is nothing to run"
|
|
305
|
+
raise RunError(msg)
|
|
306
|
+
|
|
307
|
+
dataset = load_dataset(loaded)
|
|
308
|
+
if limit:
|
|
309
|
+
dataset = dataset.limit(limit)
|
|
310
|
+
|
|
311
|
+
task = load_task(suite.task.entrypoint)
|
|
312
|
+
per_example, corpus = build_evaluators(loaded)
|
|
313
|
+
|
|
314
|
+
config = RunConfig(
|
|
315
|
+
concurrency=suite.execution.concurrency,
|
|
316
|
+
judge_concurrency=suite.execution.judge_concurrency,
|
|
317
|
+
timeout_s=suite.task.timeout_s,
|
|
318
|
+
retries=suite.task.retries,
|
|
319
|
+
max_error_rate=suite.execution.max_error_rate,
|
|
320
|
+
slice_by=suite.execution.slice_by,
|
|
321
|
+
seed=suite.execution.seed,
|
|
322
|
+
journal_path=journal,
|
|
323
|
+
resume_from=resume,
|
|
324
|
+
max_cost=Decimal(str(suite.execution.max_cost)) if suite.execution.max_cost else None,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
result = await run_suite(
|
|
328
|
+
dataset=dataset,
|
|
329
|
+
task=task,
|
|
330
|
+
evaluators=per_example,
|
|
331
|
+
corpus_evaluators=corpus,
|
|
332
|
+
gate_set=build_gate_set(loaded),
|
|
333
|
+
baseline=baseline_metrics,
|
|
334
|
+
baseline_results=baseline_results,
|
|
335
|
+
models=models,
|
|
336
|
+
config=config,
|
|
337
|
+
suite_name=suite.name,
|
|
338
|
+
judge_metrics=judge_metric_keys(loaded),
|
|
339
|
+
calibrations=resolve_calibrations(loaded),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
comparison = None
|
|
343
|
+
if baseline_metrics:
|
|
344
|
+
comparison = compare_metrics(
|
|
345
|
+
result.metrics,
|
|
346
|
+
baseline_metrics,
|
|
347
|
+
candidate_results=result.results,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return Outcome(result=result, comparison=comparison)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def exit_code_for_setup_error() -> int:
|
|
354
|
+
"""Configuration problems are exit 3, distinct from a real gate failure.
|
|
355
|
+
|
|
356
|
+
A CI job should be able to tell "your suite is broken" from "your change is
|
|
357
|
+
worse", because they call for entirely different responses.
|
|
358
|
+
"""
|
|
359
|
+
return ExitCode.CONFIGURATION_ERROR
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
__all__ = [
|
|
363
|
+
"Outcome",
|
|
364
|
+
"Plan",
|
|
365
|
+
"RunError",
|
|
366
|
+
"SuiteError",
|
|
367
|
+
"build_gate_set",
|
|
368
|
+
"execute",
|
|
369
|
+
"exit_code_for_setup_error",
|
|
370
|
+
"git_context",
|
|
371
|
+
"load_dataset",
|
|
372
|
+
"load_task",
|
|
373
|
+
"plan_run",
|
|
374
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Suite configuration."""
|