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.
- proofstep_cli-0.1.0/.gitignore +57 -0
- proofstep_cli-0.1.0/PKG-INFO +58 -0
- proofstep_cli-0.1.0/README.md +30 -0
- proofstep_cli-0.1.0/pyproject.toml +50 -0
- proofstep_cli-0.1.0/src/proofstep_cli/__init__.py +8 -0
- proofstep_cli-0.1.0/src/proofstep_cli/calibration.py +318 -0
- proofstep_cli-0.1.0/src/proofstep_cli/calibration_store.py +215 -0
- proofstep_cli-0.1.0/src/proofstep_cli/commands/__init__.py +1 -0
- proofstep_cli-0.1.0/src/proofstep_cli/main.py +562 -0
- proofstep_cli-0.1.0/src/proofstep_cli/publish.py +541 -0
- proofstep_cli-0.1.0/src/proofstep_cli/py.typed +0 -0
- proofstep_cli-0.1.0/src/proofstep_cli/registry.py +234 -0
- proofstep_cli-0.1.0/src/proofstep_cli/render/__init__.py +1 -0
- proofstep_cli-0.1.0/src/proofstep_cli/render/calibration.py +127 -0
- proofstep_cli-0.1.0/src/proofstep_cli/render/markdown.py +304 -0
- proofstep_cli-0.1.0/src/proofstep_cli/render/report.py +193 -0
- proofstep_cli-0.1.0/src/proofstep_cli/render/terminal.py +287 -0
- proofstep_cli-0.1.0/src/proofstep_cli/runner.py +374 -0
- proofstep_cli-0.1.0/src/proofstep_cli/suite/__init__.py +1 -0
- proofstep_cli-0.1.0/src/proofstep_cli/suite/loader.py +377 -0
- proofstep_cli-0.1.0/src/proofstep_cli/suite/schema.py +343 -0
- proofstep_cli-0.1.0/tests/test_calibration_command.py +422 -0
- proofstep_cli-0.1.0/tests/test_cli.py +287 -0
- proofstep_cli-0.1.0/tests/test_markdown.py +366 -0
- proofstep_cli-0.1.0/tests/test_publish.py +275 -0
- proofstep_cli-0.1.0/tests/test_suite_loader.py +362 -0
|
@@ -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]
|