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,8 @@
1
+ from importlib import metadata as _metadata
2
+
3
+ """Proofstep CLI."""
4
+
5
+ # Read from the installed distribution rather than written here twice. A hand-maintained
6
+ # copy drifts the first time a release bumps one and not the other — which it already did,
7
+ # reporting 0.1.0.dev0 from a 0.1.0 wheel.
8
+ __version__ = _metadata.version("proofstep-cli")
@@ -0,0 +1,318 @@
1
+ """The `proofstep calibrate` command: planning, running, and storing.
2
+
3
+ Kept out of `main.py` so the logic is testable without Typer, and out of
4
+ `evaluation-core` because it touches the filesystem and imports user modules.
5
+
6
+ Two paths to a report:
7
+
8
+ - **live** — call the judge over the labelled set, which costs money
9
+ - **`--verdicts`** — recompute from recorded verdicts, which costs nothing
10
+
11
+ The second is not a testing convenience bolted on. Changing a threshold, fixing a bug in
12
+ the maths, or adding a second annotator should not require paying to re-run a judge that
13
+ already answered. It also means this whole path is exercised in CI with no provider
14
+ credential, which is the only way `require_calibration` can be trusted to work on a fork
15
+ pull request.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import hashlib
22
+ import importlib
23
+ import json
24
+ import os
25
+ import sys
26
+ from dataclasses import dataclass
27
+ from decimal import Decimal
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from proofstep_cli.calibration_store import evaluator_version_hash, write_calibration
32
+ from proofstep_cli.registry import load_rubric_text
33
+ from proofstep_cli.runner import requirement_for
34
+ from proofstep_cli.suite.loader import LoadedSuite
35
+ from proofstep_cli.suite.schema import EvaluatorSpec
36
+ from proofstep_core.calibration import (
37
+ CalibrationReport,
38
+ CalibrationRequirement,
39
+ JudgeVerdict,
40
+ RequirementCheck,
41
+ calibrate,
42
+ )
43
+ from proofstep_core.calibration_runner import (
44
+ CalibrationCase,
45
+ CalibrationDataError,
46
+ assert_judge_cannot_see_labels,
47
+ load_calibration_set,
48
+ report_to_dict,
49
+ run_calibration,
50
+ summarize_labels,
51
+ total_cost_estimate,
52
+ )
53
+ from proofstep_core.evaluators.judge import LLMJudge
54
+
55
+
56
+ class CalibrationCommandError(ValueError):
57
+ """Anything that stops a calibration before it starts costing money."""
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Plan:
62
+ loaded: LoadedSuite
63
+ spec: EvaluatorSpec
64
+ judge: LLMJudge
65
+ cases: list[CalibrationCase]
66
+ labels_path: Path
67
+ labels_hash: str
68
+ version_hash: str
69
+ requirement: CalibrationRequirement
70
+ passing_labels: list[str]
71
+ ordinal_order: list[str] | None
72
+
73
+ @property
74
+ def judge_calls(self) -> int:
75
+ return total_cost_estimate(self.cases, self.judge)
76
+
77
+ @property
78
+ def label_summary(self) -> str:
79
+ return ", ".join(f"{k}={v}" for k, v in summarize_labels(self.cases).items())
80
+
81
+
82
+ def plan(loaded: LoadedSuite, *, evaluator: str, labels: Path | None) -> Plan:
83
+ """Resolve everything a calibration run needs, without calling anything."""
84
+ spec = next((e for e in loaded.suite.evaluators if e.name == evaluator), None)
85
+ if spec is None:
86
+ available = ", ".join(e.name for e in loaded.suite.evaluators) or "<none>"
87
+ msg = f"suite has no evaluator named {evaluator!r}. Available: {available}"
88
+ raise CalibrationCommandError(msg)
89
+ if spec.type != "llm_judge":
90
+ # Calibration measures whether an *opinion* matches a human's. A deterministic
91
+ # check has no opinion — `exact_match` either matched or it did not — so
92
+ # calibrating one would produce a meaningless certificate.
93
+ msg = (
94
+ f"evaluator {evaluator!r} is type {spec.type!r}, not 'llm_judge'. Only judges "
95
+ "need calibration: a deterministic check has no opinion to validate."
96
+ )
97
+ raise CalibrationCommandError(msg)
98
+
99
+ labels_path = _resolve_labels(loaded, spec, labels)
100
+ try:
101
+ cases = load_calibration_set(labels_path)
102
+ except CalibrationDataError as exc:
103
+ raise CalibrationCommandError(str(exc)) from exc
104
+
105
+ rubric = load_rubric_text(spec, loaded)
106
+ judge = _build_judge(spec, rubric)
107
+ try:
108
+ assert_judge_cannot_see_labels(judge)
109
+ except CalibrationDataError as exc:
110
+ raise CalibrationCommandError(str(exc)) from exc
111
+
112
+ passing = list(spec.calibration.passing_labels) if spec.calibration else []
113
+ return Plan(
114
+ loaded=loaded,
115
+ spec=spec,
116
+ judge=judge,
117
+ cases=cases,
118
+ labels_path=labels_path,
119
+ labels_hash=_hash_file(labels_path),
120
+ version_hash=evaluator_version_hash(spec, rubric=rubric),
121
+ requirement=requirement_for(spec),
122
+ passing_labels=passing,
123
+ ordinal_order=_ordinal_order(spec),
124
+ )
125
+
126
+
127
+ def _resolve_labels(loaded: LoadedSuite, spec: EvaluatorSpec, override: Path | None) -> Path:
128
+ if override is not None:
129
+ path = override if override.is_absolute() else Path.cwd() / override
130
+ if not path.exists():
131
+ msg = f"labelled set not found: {path}"
132
+ raise CalibrationCommandError(msg)
133
+ return path
134
+
135
+ if spec.calibration is None or not spec.calibration.dataset:
136
+ msg = (
137
+ f"judge {spec.name!r} declares no `calibration.dataset`, and no --labels was "
138
+ "given. Point one of them at a human-labelled JSONL file."
139
+ )
140
+ raise CalibrationCommandError(msg)
141
+ path = loaded.resolve_path(spec.calibration.dataset)
142
+ if not path.exists():
143
+ msg = f"labelled set not found: {path} (from calibration.dataset)"
144
+ raise CalibrationCommandError(msg)
145
+ return path
146
+
147
+
148
+ def _build_judge(spec: EvaluatorSpec, rubric: str) -> LLMJudge:
149
+ assert spec.model is not None # guaranteed by suite validation
150
+ return LLMJudge(
151
+ name=spec.name,
152
+ rubric=rubric,
153
+ model=spec.model,
154
+ inputs=spec.inputs,
155
+ mode="classify" if spec.labels else "rubric",
156
+ labels=spec.labels or None,
157
+ scale=(spec.scale.min, spec.scale.max),
158
+ normalize=spec.scale.normalize,
159
+ temperature=spec.temperature,
160
+ seed=spec.seed,
161
+ votes=spec.votes,
162
+ timeout_s=spec.timeout_s,
163
+ max_retries=spec.max_retries,
164
+ )
165
+
166
+
167
+ def _ordinal_order(spec: EvaluatorSpec) -> list[str] | None:
168
+ """The rubric scale as ordered labels, or None for a classifier.
169
+
170
+ A classifier's labels have no order — "spam" is not between "ham" and "promo" — so
171
+ weighting a near miss would be meaningless. A 1-5 rubric does, and treating "4 where
172
+ the human said 5" as a total miss is not a defensible way to grade a scale.
173
+ """
174
+ if spec.labels:
175
+ return None
176
+ return [str(value) for value in range(spec.scale.min, spec.scale.max + 1)]
177
+
178
+
179
+ def produce(
180
+ plan_: Plan,
181
+ *,
182
+ verdicts_path: Path | None,
183
+ model_client: str | None,
184
+ concurrency: int,
185
+ ) -> CalibrationReport:
186
+ """Get a report, either from recorded verdicts or by running the judge."""
187
+ if verdicts_path is not None:
188
+ verdicts = _load_verdicts(verdicts_path)
189
+ return calibrate(
190
+ [case.labelled for case in plan_.cases],
191
+ verdicts,
192
+ passing_labels=plan_.passing_labels or None,
193
+ ordinal_order=plan_.ordinal_order,
194
+ )
195
+
196
+ models = load_model_client(model_client)
197
+ return asyncio.run(
198
+ run_calibration(
199
+ plan_.judge,
200
+ plan_.cases,
201
+ models,
202
+ concurrency=concurrency,
203
+ passing_labels=plan_.passing_labels or None,
204
+ ordinal_order=plan_.ordinal_order,
205
+ )
206
+ )
207
+
208
+
209
+ def _load_verdicts(path: Path) -> list[JudgeVerdict]:
210
+ """Read recorded judge verdicts.
211
+
212
+ One JSON object per line: `{"id": ..., "label": ..., "cost": ..., "latency_ms": ...}`,
213
+ or `{"id": ..., "error": "..."}` for a call that failed. An errored verdict is
214
+ preserved as an error rather than dropped: excluding failures silently would make a
215
+ judge that times out on hard examples look better than one that answers them badly.
216
+ """
217
+ if not path.exists():
218
+ msg = f"verdicts file not found: {path}"
219
+ raise CalibrationCommandError(msg)
220
+
221
+ verdicts: list[JudgeVerdict] = []
222
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
223
+ if not line.strip():
224
+ continue
225
+ try:
226
+ row = json.loads(line)
227
+ except json.JSONDecodeError as exc:
228
+ msg = f"{path}:{number}: not valid JSON — {exc}"
229
+ raise CalibrationCommandError(msg) from exc
230
+ if not isinstance(row, dict) or not row.get("id"):
231
+ msg = f"{path}:{number}: every verdict needs an 'id'"
232
+ raise CalibrationCommandError(msg)
233
+
234
+ error = row.get("error")
235
+ verdicts.append(
236
+ JudgeVerdict(
237
+ example_id=str(row["id"]),
238
+ label=None if error else _optional_str(row.get("label")),
239
+ errored=bool(error),
240
+ error=str(error) if error else None,
241
+ cost=Decimal(str(row.get("cost", "0"))),
242
+ latency_ms=int(row.get("latency_ms") or 0),
243
+ )
244
+ )
245
+ if not verdicts:
246
+ msg = f"{path} contains no verdicts"
247
+ raise CalibrationCommandError(msg)
248
+ return verdicts
249
+
250
+
251
+ def _optional_str(value: Any) -> str | None:
252
+ return None if value is None or value == "" else str(value)
253
+
254
+
255
+ def load_model_client(entrypoint: str | None) -> Any:
256
+ """Import a `module:factory` that returns a ModelClient.
257
+
258
+ Provider adapters are not part of this package — `evaluation-core` must stay free of
259
+ provider SDKs for local mode to work at all — so the judge's model access is supplied
260
+ by the project being evaluated.
261
+ """
262
+ target = entrypoint or os.environ.get("PROOFSTEP_MODEL_CLIENT")
263
+ if not target:
264
+ msg = (
265
+ "no model client. Pass --model-client module:factory, set "
266
+ "PROOFSTEP_MODEL_CLIENT, or use --verdicts to recompute from recorded "
267
+ "judge output without calling a model."
268
+ )
269
+ raise CalibrationCommandError(msg)
270
+
271
+ module_name, _, attribute = target.partition(":")
272
+ if not attribute:
273
+ msg = f"--model-client must be 'module:factory', got {target!r}"
274
+ raise CalibrationCommandError(msg)
275
+
276
+ cwd = str(Path.cwd())
277
+ if cwd not in sys.path:
278
+ sys.path.insert(0, cwd)
279
+ try:
280
+ module = importlib.import_module(module_name)
281
+ except ImportError as exc:
282
+ msg = f"cannot import model client module {module_name!r}: {exc}"
283
+ raise CalibrationCommandError(msg) from exc
284
+
285
+ factory = getattr(module, attribute, None)
286
+ if factory is None:
287
+ msg = f"module {module_name!r} has no attribute {attribute!r}"
288
+ raise CalibrationCommandError(msg)
289
+ return factory() if callable(factory) else factory
290
+
291
+
292
+ def store(plan_: Plan, report: CalibrationReport, check: RequirementCheck) -> Path:
293
+ """Write the record CI will read."""
294
+ directory = plan_.loaded.resolve_path(plan_.loaded.suite.calibration.directory)
295
+ return write_calibration(
296
+ directory,
297
+ evaluator=plan_.spec.name,
298
+ version_hash=plan_.version_hash,
299
+ report=report_to_dict(report),
300
+ requirement={
301
+ "min_agreement": plan_.requirement.min_agreement,
302
+ "min_kappa": plan_.requirement.min_kappa,
303
+ "max_false_pass_rate": plan_.requirement.max_false_pass_rate,
304
+ "max_false_fail_rate": plan_.requirement.max_false_fail_rate,
305
+ "min_examples": plan_.requirement.min_examples,
306
+ "min_per_class": plan_.requirement.min_per_class,
307
+ "allow_position_bias": plan_.requirement.allow_position_bias,
308
+ },
309
+ satisfied=check.satisfied,
310
+ failures=list(check.failures),
311
+ warnings=list(check.warnings),
312
+ labels_path=str(plan_.labels_path.name),
313
+ labels_hash=plan_.labels_hash,
314
+ )
315
+
316
+
317
+ def _hash_file(path: Path) -> str:
318
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
@@ -0,0 +1,215 @@
1
+ """Reading and writing calibration evidence as files in the repository.
2
+
3
+ Calibration reports are **committed to git**, not kept only in a server. Three reasons,
4
+ in order of importance:
5
+
6
+ 1. `require_calibration` has to work in CI with no server and no credentials. The
7
+ offline-suite story (docs/GITHUB_ACTIONS.md) is what makes fork pull requests safe,
8
+ and a gate that silently degrades to "uncalibrated" whenever the server is
9
+ unreachable would be worse than no gate.
10
+ 2. A calibration is evidence about a judgement, and evidence belongs in review. A
11
+ reviewer should see "this rubric change also changed the false-pass rate from 0.02 to
12
+ 0.14" in the diff.
13
+ 3. The filename carries the evaluator's config hash, so a rubric edit produces a *new*
14
+ filename. The old report stays put and the gate says "stale" instead of quietly
15
+ applying yesterday's evidence to today's judge.
16
+
17
+ The server copy (Phase 4c tables) remains the source of truth for dashboards and
18
+ cross-run history. This is the copy CI reads.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from dataclasses import dataclass
25
+ from datetime import UTC, datetime
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from proofstep_core.versioning import config_hash
30
+ from proofstep_types import CalibrationStatus
31
+
32
+ DEFAULT_DIRECTORY = "calibrations"
33
+ SUFFIX = ".calibration.json"
34
+
35
+
36
+ class CalibrationStoreError(ValueError):
37
+ """A stored calibration file could not be read."""
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class StoredCalibration:
42
+ path: Path
43
+ evaluator: str
44
+ version_hash: str
45
+ report: dict[str, Any]
46
+ requirement: dict[str, Any]
47
+ satisfied: bool
48
+ failures: list[str]
49
+ warnings: list[str]
50
+ calibrated_at: datetime | None
51
+
52
+
53
+ def filename(evaluator: str, version_hash: str) -> str:
54
+ return f"{evaluator}.{version_hash}{SUFFIX}"
55
+
56
+
57
+ def write_calibration(
58
+ directory: Path,
59
+ *,
60
+ evaluator: str,
61
+ version_hash: str,
62
+ report: dict[str, Any],
63
+ requirement: dict[str, Any],
64
+ satisfied: bool,
65
+ failures: list[str],
66
+ warnings: list[str],
67
+ labels_path: str | None = None,
68
+ labels_hash: str | None = None,
69
+ ) -> Path:
70
+ """Write one calibration record, returning its path."""
71
+ directory.mkdir(parents=True, exist_ok=True)
72
+ target = directory / filename(evaluator, version_hash)
73
+
74
+ payload = {
75
+ "schema": 1,
76
+ "evaluator": evaluator,
77
+ "evaluator_version_hash": version_hash,
78
+ "calibrated_at": datetime.now(UTC).isoformat(),
79
+ # Recorded so a reviewer can tell whether a changed number came from a changed
80
+ # judge or a changed labelled set. Without it, "agreement fell to 0.7" is
81
+ # unattributable.
82
+ "labels_path": labels_path,
83
+ "labels_hash": labels_hash,
84
+ "requirement": requirement,
85
+ "satisfied": satisfied,
86
+ "failures": failures,
87
+ "warnings": warnings,
88
+ "report": report,
89
+ }
90
+ # Indented and newline-terminated: this file is read in pull requests.
91
+ target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
92
+ return target
93
+
94
+
95
+ def read_calibration(path: Path) -> StoredCalibration:
96
+ try:
97
+ payload = json.loads(path.read_text(encoding="utf-8"))
98
+ except (OSError, json.JSONDecodeError) as exc:
99
+ msg = f"{path}: cannot read calibration record — {exc}"
100
+ raise CalibrationStoreError(msg) from exc
101
+ if not isinstance(payload, dict):
102
+ msg = f"{path}: expected a JSON object"
103
+ raise CalibrationStoreError(msg)
104
+
105
+ stamp = payload.get("calibrated_at")
106
+ when: datetime | None = None
107
+ if isinstance(stamp, str):
108
+ try:
109
+ when = datetime.fromisoformat(stamp)
110
+ except ValueError:
111
+ when = None
112
+
113
+ return StoredCalibration(
114
+ path=path,
115
+ evaluator=str(payload.get("evaluator", "")),
116
+ version_hash=str(payload.get("evaluator_version_hash", "")),
117
+ report=payload.get("report") or {},
118
+ requirement=payload.get("requirement") or {},
119
+ satisfied=bool(payload.get("satisfied")),
120
+ failures=list(payload.get("failures") or []),
121
+ warnings=list(payload.get("warnings") or []),
122
+ calibrated_at=when,
123
+ )
124
+
125
+
126
+ def load_all(directory: Path) -> list[StoredCalibration]:
127
+ """Every calibration record in a directory, newest first.
128
+
129
+ A malformed file is an error rather than a skip. Skipping it would present as "this
130
+ judge was never calibrated", which is the same signal as a missing file and hides a
131
+ fixable problem.
132
+ """
133
+ if not directory.exists():
134
+ return []
135
+ records = [read_calibration(path) for path in sorted(directory.glob(f"*{SUFFIX}"))]
136
+ records.sort(key=lambda r: r.calibrated_at or datetime.min.replace(tzinfo=UTC), reverse=True)
137
+ return records
138
+
139
+
140
+ def status_for(
141
+ records: list[StoredCalibration], *, evaluator: str, metric_key: str, version_hash: str
142
+ ) -> CalibrationStatus:
143
+ """Resolve one judge's calibration state, including staleness.
144
+
145
+ Three outcomes: a record for this exact version; a record for a *different* version
146
+ of the same evaluator (stale — the rubric, model, or parameters changed, so the old
147
+ evidence does not describe this judge); or nothing at all.
148
+ """
149
+ for record in records:
150
+ if record.evaluator == evaluator and record.version_hash == version_hash:
151
+ report = record.report
152
+ return CalibrationStatus(
153
+ metric_key=metric_key,
154
+ evaluator_name=evaluator,
155
+ evaluator_version_hash=version_hash,
156
+ calibrated=True,
157
+ satisfied=record.satisfied,
158
+ failures=record.failures,
159
+ warnings=record.warnings,
160
+ n_examples=int(report.get("n_examples") or 0),
161
+ agreement=_opt_float(report.get("agreement")),
162
+ kappa=_opt_float(report.get("kappa")),
163
+ false_pass_rate=_opt_float(report.get("false_pass_rate")),
164
+ at_human_ceiling=bool(report.get("at_human_ceiling")),
165
+ calibrated_at=record.calibrated_at,
166
+ )
167
+
168
+ stale = next((r for r in records if r.evaluator == evaluator), None)
169
+ if stale is not None:
170
+ return CalibrationStatus(
171
+ metric_key=metric_key,
172
+ evaluator_name=evaluator,
173
+ evaluator_version_hash=stale.version_hash,
174
+ calibrated=True,
175
+ satisfied=stale.satisfied,
176
+ n_examples=int((stale.report or {}).get("n_examples") or 0),
177
+ kappa=_opt_float((stale.report or {}).get("kappa")),
178
+ stale_for_version=version_hash,
179
+ )
180
+
181
+ return CalibrationStatus(metric_key=metric_key, evaluator_name=evaluator, calibrated=False)
182
+
183
+
184
+ def _opt_float(value: Any) -> float | None:
185
+ if value is None:
186
+ return None
187
+ try:
188
+ return float(value)
189
+ except (TypeError, ValueError):
190
+ return None
191
+
192
+
193
+ def evaluator_version_hash(spec: Any, *, rubric: str) -> str:
194
+ """The config hash of a judge, as the gate engine and the server both compute it.
195
+
196
+ The rubric *text* goes in, not its path. Editing `rubrics/groundedness.md` in place
197
+ changes the metric's definition, so it must change the version — otherwise a rubric
198
+ edit silently redefines the ruler while keeping the old calibration's blessing, which
199
+ is precisely the "rubric drift" failure this system is meant to catch.
200
+ """
201
+ return config_hash(
202
+ {
203
+ "type": spec.type,
204
+ "name": spec.name,
205
+ "mode": "classify" if spec.labels else "rubric",
206
+ "model": spec.model,
207
+ "rubric": rubric,
208
+ "inputs": sorted(spec.inputs),
209
+ "labels": sorted(spec.labels) if spec.labels else None,
210
+ "scale": [spec.scale.min, spec.scale.max, spec.scale.normalize],
211
+ "temperature": spec.temperature,
212
+ "seed": spec.seed,
213
+ "votes": spec.votes,
214
+ }
215
+ )
@@ -0,0 +1 @@
1
+ """CLI subcommands."""