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
|
@@ -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."""
|