proofstep-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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."""