proofstep-cli 0.1.0__tar.gz

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,57 @@
1
+
2
+ __pycache__/
3
+ .coverage
4
+ .coverage.*
5
+ .docker-data/
6
+ .DS_Store
7
+ .e2e-api.log
8
+ .env
9
+ .env.*
10
+ .env.prod
11
+ # Exceptions, and they must come *after* the patterns above: git takes the last matching rule, so a
12
+ # negation written earlier in the file is silently overridden. That is not a hypothetical — the `!`
13
+ # line used to sit at the top, `.env.*` re-ignored it, and `.env.prod.example` was never committed.
14
+ # `scripts/init_secrets.sh` reads that file, so the first documented step of self-hosting failed for
15
+ # anyone who cloned the repository. It was caught by CI running the same step.
16
+ !.env.example
17
+ !.env.prod.example
18
+ .hypothesis/
19
+ .idea/
20
+ .mypy_cache/
21
+ .next/
22
+ .proofstep/
23
+ .pytest_cache/
24
+ .ruff_cache/
25
+ .turbo/
26
+ .venv/
27
+ .vscode/
28
+ *.egg-info/
29
+ *.key
30
+ *.pem
31
+ *.proofstep.local.yaml
32
+ *.py[cod]
33
+ *.swp
34
+ *.tsbuildinfo
35
+ # Database dumps. Never committed: they contain every tenant's data, and a backup in a git history
36
+ # Docker volumes
37
+ # Editors / OS
38
+ # is a backup with no access control and no retention.
39
+ # Node
40
+ # Proofstep local state
41
+ # Python
42
+ # Reports a local run drops in the working tree. The name comes from the suite, so the pattern has
43
+ # Secrets and local config — never commit these
44
+ # The e2e stack's server log, written next to the repo so a CI failure can print it.
45
+ # to cover all of them rather than the default filename only.
46
+ ~/.proofstep/
47
+ backups/
48
+ build/
49
+ coverage.xml
50
+ credentials.json
51
+ dist/
52
+ htmlcov/
53
+ node_modules/
54
+ out/
55
+ proofstep-*.json
56
+ secrets/
57
+ venv/
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.5
2
+ Name: proofstep-cli
3
+ Version: 0.1.0
4
+ Summary: Proofstep command-line interface — run suites, gate CI, compare experiments
5
+ Project-URL: Homepage, https://github.com/IlaKhan17/proofstep
6
+ Project-URL: Documentation, https://github.com/IlaKhan17/proofstep/tree/main/docs
7
+ Project-URL: Repository, https://github.com/IlaKhan17/proofstep
8
+ Project-URL: Issues, https://github.com/IlaKhan17/proofstep/issues
9
+ License-Expression: Apache-2.0
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: proofstep
21
+ Requires-Dist: proofstep-core
22
+ Requires-Dist: proofstep-trajectory
23
+ Requires-Dist: proofstep-types
24
+ Requires-Dist: pyyaml>=6.0
25
+ Requires-Dist: rich>=13.9
26
+ Requires-Dist: typer>=0.15
27
+ Description-Content-Type: text/markdown
28
+
29
+ # proofstep-cli
30
+
31
+ **The `proofstep` command** — part of [Proofstep](https://github.com/IlaKhan17/proofstep), the CI gate for AI
32
+ agents that knows the difference between a regression and a bad day.
33
+
34
+ Run an evaluation suite, apply its gates, and exit non-zero when a protected metric regresses.
35
+
36
+ ```bash
37
+ proofstep eval evals/suites/reply-intent.yaml
38
+ echo $? # 0 merge · 1 a blocking gate failed · 2 execution error · 3 the suite is wrong
39
+ ```
40
+
41
+ The exit code is the contract. Everything else — the terminal table, the JSON report, the
42
+ pull-request comment — exists to explain it.
43
+
44
+ Gates can be statistically honest rather than just thresholded:
45
+
46
+ ```yaml
47
+ gates:
48
+ intent_accuracy:
49
+ max_regression: 0.02
50
+ significance: 0.05 # only fail if the drop is distinguishable from noise
51
+ require_power: true # and ERROR if this run could never have detected it
52
+ ```
53
+
54
+ ## Documentation
55
+
56
+ Full documentation lives in the [repository](https://github.com/IlaKhan17/proofstep/tree/main/docs).
57
+
58
+ Apache-2.0.
@@ -0,0 +1,30 @@
1
+ # proofstep-cli
2
+
3
+ **The `proofstep` command** — part of [Proofstep](https://github.com/IlaKhan17/proofstep), the CI gate for AI
4
+ agents that knows the difference between a regression and a bad day.
5
+
6
+ Run an evaluation suite, apply its gates, and exit non-zero when a protected metric regresses.
7
+
8
+ ```bash
9
+ proofstep eval evals/suites/reply-intent.yaml
10
+ echo $? # 0 merge · 1 a blocking gate failed · 2 execution error · 3 the suite is wrong
11
+ ```
12
+
13
+ The exit code is the contract. Everything else — the terminal table, the JSON report, the
14
+ pull-request comment — exists to explain it.
15
+
16
+ Gates can be statistically honest rather than just thresholded:
17
+
18
+ ```yaml
19
+ gates:
20
+ intent_accuracy:
21
+ max_regression: 0.02
22
+ significance: 0.05 # only fail if the drop is distinguishable from noise
23
+ require_power: true # and ERROR if this run could never have detected it
24
+ ```
25
+
26
+ ## Documentation
27
+
28
+ Full documentation lives in the [repository](https://github.com/IlaKhan17/proofstep/tree/main/docs).
29
+
30
+ Apache-2.0.
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "proofstep-cli"
3
+ version = "0.1.0"
4
+ description = "Proofstep command-line interface — run suites, gate CI, compare experiments"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "Apache-2.0"
8
+ classifiers = [
9
+ "Development Status :: 4 - Beta",
10
+ "Intended Audience :: Developers",
11
+ "License :: OSI Approved :: Apache Software License",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Topic :: Software Development :: Testing",
15
+ "Topic :: Software Development :: Quality Assurance",
16
+ "Typing :: Typed",
17
+ ]
18
+
19
+ dependencies = [
20
+ "proofstep",
21
+ "proofstep-core",
22
+ "proofstep-trajectory",
23
+ "proofstep-types",
24
+ "typer>=0.15",
25
+ "rich>=13.9",
26
+ "pyyaml>=6.0",
27
+ "httpx>=0.27",
28
+ ]
29
+
30
+ [project.scripts]
31
+ proofstep = "proofstep_cli.main:app"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/IlaKhan17/proofstep"
35
+ Documentation = "https://github.com/IlaKhan17/proofstep/tree/main/docs"
36
+ Repository = "https://github.com/IlaKhan17/proofstep"
37
+ Issues = "https://github.com/IlaKhan17/proofstep/issues"
38
+
39
+ [build-system]
40
+ requires = ["hatchling"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/proofstep_cli"]
45
+
46
+ [tool.uv.sources]
47
+ proofstep = { workspace = true }
48
+ proofstep-core = { workspace = true }
49
+ proofstep-trajectory = { workspace = true }
50
+ proofstep-types = { workspace = true }
@@ -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]