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,377 @@
|
|
|
1
|
+
"""Suite loading: interpolation, composition, validation.
|
|
2
|
+
|
|
3
|
+
Everything here happens *before* a single model call. A suite of 500 examples across
|
|
4
|
+
six judges is real money, so a misconfiguration must fail in milliseconds rather
|
|
5
|
+
than after the spend. Errors carry the file and line so the fix is obvious.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from proofstep_cli.suite.schema import Suite
|
|
20
|
+
|
|
21
|
+
INTERPOLATION = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
|
|
22
|
+
|
|
23
|
+
# Never persisted to the server, whatever a suite says. A provider key that reaches
|
|
24
|
+
# our storage is a leak we caused.
|
|
25
|
+
SECRET_HINTS = ("key", "token", "secret", "password", "credential")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SuiteError(ValueError):
|
|
29
|
+
"""A suite could not be loaded, or is semantically invalid."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class LoadedSuite:
|
|
34
|
+
suite: Suite
|
|
35
|
+
path: Path
|
|
36
|
+
raw: dict[str, Any]
|
|
37
|
+
hints: list[str] = field(default_factory=list)
|
|
38
|
+
resolved_secrets: set[str] = field(default_factory=set)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def directory(self) -> Path:
|
|
42
|
+
return self.path.parent
|
|
43
|
+
|
|
44
|
+
def resolve_path(self, relative: str) -> Path:
|
|
45
|
+
"""Resolve a path referenced by the suite, relative to the suite file.
|
|
46
|
+
|
|
47
|
+
Relative to the *suite*, not the working directory: a suite must behave the
|
|
48
|
+
same whether it is run from the repo root or from its own folder.
|
|
49
|
+
"""
|
|
50
|
+
candidate = Path(relative)
|
|
51
|
+
return candidate if candidate.is_absolute() else (self.directory / candidate).resolve()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_suite(
|
|
55
|
+
path: str | Path, *, overrides: dict[str, str] | None = None, _depth: int = 0
|
|
56
|
+
) -> LoadedSuite:
|
|
57
|
+
file_path = Path(path).resolve()
|
|
58
|
+
if not file_path.exists():
|
|
59
|
+
msg = f"suite file not found: {file_path}"
|
|
60
|
+
raise SuiteError(msg)
|
|
61
|
+
|
|
62
|
+
source = file_path.read_text(encoding="utf-8")
|
|
63
|
+
raw = _parse_yaml(source, file_path)
|
|
64
|
+
|
|
65
|
+
if (parent := raw.get("extends")) is not None:
|
|
66
|
+
if _depth >= 1:
|
|
67
|
+
# One level only. Deep config inheritance is a well-known trap: the
|
|
68
|
+
# effective configuration becomes something you have to execute to know.
|
|
69
|
+
msg = (
|
|
70
|
+
f"{file_path}: `extends` may only be one level deep. "
|
|
71
|
+
f"{parent!r} itself extends another file."
|
|
72
|
+
)
|
|
73
|
+
raise SuiteError(msg)
|
|
74
|
+
base_path = (file_path.parent / parent).resolve()
|
|
75
|
+
base = load_suite(base_path, _depth=_depth + 1)
|
|
76
|
+
raw = _merge(base.raw, raw)
|
|
77
|
+
raw.pop("extends", None)
|
|
78
|
+
|
|
79
|
+
secrets: set[str] = set()
|
|
80
|
+
raw = _interpolate(raw, file_path, secrets)
|
|
81
|
+
|
|
82
|
+
if overrides:
|
|
83
|
+
for dotted, value in overrides.items():
|
|
84
|
+
_apply_override(raw, dotted, value, file_path)
|
|
85
|
+
|
|
86
|
+
lines = _key_lines(source)
|
|
87
|
+
try:
|
|
88
|
+
suite = Suite.model_validate(raw)
|
|
89
|
+
except ValidationError as exc:
|
|
90
|
+
raise SuiteError(_format_validation(exc, file_path, lines)) from exc
|
|
91
|
+
|
|
92
|
+
loaded = LoadedSuite(suite=suite, path=file_path, raw=raw, resolved_secrets=secrets)
|
|
93
|
+
loaded.hints = _semantic_checks(loaded, lines)
|
|
94
|
+
return loaded
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------- parsing
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_yaml(source: str, path: Path) -> dict[str, Any]:
|
|
101
|
+
try:
|
|
102
|
+
parsed = yaml.safe_load(source)
|
|
103
|
+
except yaml.YAMLError as exc:
|
|
104
|
+
mark = getattr(exc, "problem_mark", None)
|
|
105
|
+
where = f"{path}:{mark.line + 1}:{mark.column + 1}" if mark else str(path)
|
|
106
|
+
problem = getattr(exc, "problem", str(exc))
|
|
107
|
+
msg = f"{where}: invalid YAML: {problem}"
|
|
108
|
+
raise SuiteError(msg) from exc
|
|
109
|
+
|
|
110
|
+
if not isinstance(parsed, dict):
|
|
111
|
+
msg = f"{path}: a suite must be a YAML mapping, got {type(parsed).__name__}"
|
|
112
|
+
raise SuiteError(msg)
|
|
113
|
+
return parsed
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _key_lines(source: str) -> dict[str, int]:
|
|
117
|
+
"""Map keys and `name:` values to 1-based line numbers.
|
|
118
|
+
|
|
119
|
+
Good enough to point a human at the right place, which is all a line number
|
|
120
|
+
needs to do.
|
|
121
|
+
"""
|
|
122
|
+
lines: dict[str, int] = {}
|
|
123
|
+
for number, text in enumerate(source.splitlines(), start=1):
|
|
124
|
+
stripped = text.strip().lstrip("- ")
|
|
125
|
+
if ":" not in stripped or stripped.startswith("#"):
|
|
126
|
+
continue
|
|
127
|
+
key, _, value = stripped.partition(":")
|
|
128
|
+
key = key.strip()
|
|
129
|
+
lines.setdefault(key, number)
|
|
130
|
+
cleaned = value.strip().strip("\"'")
|
|
131
|
+
if key == "name" and cleaned:
|
|
132
|
+
lines.setdefault(f"name={cleaned}", number)
|
|
133
|
+
return lines
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _merge(base: dict[str, Any], child: dict[str, Any]) -> dict[str, Any]:
|
|
137
|
+
"""Shallow merge; lists replace rather than concatenate.
|
|
138
|
+
|
|
139
|
+
Concatenating would make it impossible for a child suite to *remove* an
|
|
140
|
+
inherited evaluator, and silently accumulating them is worse than a redeclare.
|
|
141
|
+
"""
|
|
142
|
+
merged = dict(base)
|
|
143
|
+
for key, value in child.items():
|
|
144
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
145
|
+
merged[key] = {**merged[key], **value}
|
|
146
|
+
else:
|
|
147
|
+
merged[key] = value
|
|
148
|
+
return merged
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------- interpolation
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _interpolate(value: Any, path: Path, secrets: set[str]) -> Any:
|
|
155
|
+
if isinstance(value, dict):
|
|
156
|
+
return {k: _interpolate(v, path, secrets) for k, v in value.items()}
|
|
157
|
+
if isinstance(value, list):
|
|
158
|
+
return [_interpolate(v, path, secrets) for v in value]
|
|
159
|
+
if not isinstance(value, str):
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
def replace(match: re.Match[str]) -> str:
|
|
163
|
+
name, default = match.group(1), match.group(2)
|
|
164
|
+
resolved = os.environ.get(name)
|
|
165
|
+
if resolved is None:
|
|
166
|
+
if default is None:
|
|
167
|
+
# A missing variable with no default is an error, not an empty
|
|
168
|
+
# string: silently substituting "" produces a suite that runs and
|
|
169
|
+
# measures the wrong thing.
|
|
170
|
+
msg = (
|
|
171
|
+
f"{path}: environment variable {name!r} is not set and has no default. "
|
|
172
|
+
f"Use ${{{name}:-fallback}} to provide one."
|
|
173
|
+
)
|
|
174
|
+
raise SuiteError(msg)
|
|
175
|
+
resolved = default
|
|
176
|
+
if any(hint in name.lower() for hint in SECRET_HINTS):
|
|
177
|
+
secrets.add(name)
|
|
178
|
+
return resolved
|
|
179
|
+
|
|
180
|
+
return INTERPOLATION.sub(replace, value)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _apply_override(raw: dict[str, Any], dotted: str, value: str, path: Path) -> None:
|
|
184
|
+
"""Apply `--set a.b.c=value`, coercing to the existing value's type."""
|
|
185
|
+
parts = dotted.split(".")
|
|
186
|
+
cursor: Any = raw
|
|
187
|
+
for part in parts[:-1]:
|
|
188
|
+
if not isinstance(cursor, dict) or part not in cursor:
|
|
189
|
+
msg = f"{path}: --set {dotted} does not match any field in the suite"
|
|
190
|
+
raise SuiteError(msg)
|
|
191
|
+
cursor = cursor[part]
|
|
192
|
+
|
|
193
|
+
leaf = parts[-1]
|
|
194
|
+
if not isinstance(cursor, dict) or leaf not in cursor:
|
|
195
|
+
msg = f"{path}: --set {dotted} does not match any field in the suite"
|
|
196
|
+
raise SuiteError(msg)
|
|
197
|
+
cursor[leaf] = _coerce(cursor[leaf], value)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _coerce(existing: Any, value: str) -> Any:
|
|
201
|
+
if isinstance(existing, bool):
|
|
202
|
+
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
203
|
+
if isinstance(existing, int) and not isinstance(existing, bool):
|
|
204
|
+
return int(value)
|
|
205
|
+
if isinstance(existing, float):
|
|
206
|
+
return float(value)
|
|
207
|
+
return value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ------------------------------------------------------------------ validation
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _format_validation(exc: ValidationError, path: Path, lines: dict[str, int]) -> str:
|
|
214
|
+
parts = [f"{path}: suite is invalid:"]
|
|
215
|
+
for error in exc.errors():
|
|
216
|
+
location = [str(p) for p in error["loc"]]
|
|
217
|
+
field_path = ".".join(p for p in location if not p.isdigit())
|
|
218
|
+
line = lines.get(location[-1]) or lines.get(location[0]) if location else None
|
|
219
|
+
prefix = f" {path}:{line}: " if line else " "
|
|
220
|
+
parts.append(f"{prefix}{field_path or '<root>'}: {error['msg']}")
|
|
221
|
+
return "\n".join(parts)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _semantic_checks(loaded: LoadedSuite, lines: dict[str, int]) -> list[str]:
|
|
225
|
+
"""Checks the type system cannot express. Errors raise; advice is returned."""
|
|
226
|
+
suite = loaded.suite
|
|
227
|
+
path = loaded.path
|
|
228
|
+
produced = _declared_metrics(suite)
|
|
229
|
+
|
|
230
|
+
for metric_key, gate in suite.gates.items():
|
|
231
|
+
root = metric_key.split("[")[0]
|
|
232
|
+
if not any(root == p or root.startswith(f"{p}_") for p in produced):
|
|
233
|
+
# An error, not a warning. A gate naming a metric nothing produces is the
|
|
234
|
+
# single most dangerous configuration bug: CI goes green while measuring
|
|
235
|
+
# nothing, and nobody ever looks again.
|
|
236
|
+
line = lines.get(metric_key)
|
|
237
|
+
at = f"{path}:{line}: " if line else f"{path}: "
|
|
238
|
+
msg = (
|
|
239
|
+
f"{at}gate on {metric_key!r} matches no evaluator. "
|
|
240
|
+
f"Declared evaluators: {', '.join(sorted(produced)) or '<none>'}"
|
|
241
|
+
)
|
|
242
|
+
raise SuiteError(msg)
|
|
243
|
+
if gate.max_regression is not None and suite.baseline.strategy == "none":
|
|
244
|
+
msg = (
|
|
245
|
+
f"{path}: gate on {metric_key!r} sets max_regression but the suite's baseline "
|
|
246
|
+
"strategy is 'none', so there is nothing to regress against."
|
|
247
|
+
)
|
|
248
|
+
raise SuiteError(msg)
|
|
249
|
+
|
|
250
|
+
for evaluator in suite.evaluators:
|
|
251
|
+
for attribute in ("schema_path", "rubric_path", "policy"):
|
|
252
|
+
reference = getattr(evaluator, attribute, None)
|
|
253
|
+
if reference and not loaded.resolve_path(reference).exists():
|
|
254
|
+
line = lines.get(f"name={evaluator.name}")
|
|
255
|
+
at = f"{path}:{line}: " if line else f"{path}: "
|
|
256
|
+
msg = (
|
|
257
|
+
f"{at}evaluator {evaluator.name!r} references {attribute}="
|
|
258
|
+
f"{reference!r}, which does not exist "
|
|
259
|
+
f"(resolved to {loaded.resolve_path(reference)})"
|
|
260
|
+
)
|
|
261
|
+
raise SuiteError(msg)
|
|
262
|
+
|
|
263
|
+
_check_metric_collisions(suite, path)
|
|
264
|
+
|
|
265
|
+
if suite.dataset.is_local:
|
|
266
|
+
assert suite.dataset.path is not None
|
|
267
|
+
if not loaded.resolve_path(suite.dataset.path).exists():
|
|
268
|
+
msg = f"{path}: dataset path {suite.dataset.path!r} does not exist"
|
|
269
|
+
raise SuiteError(msg)
|
|
270
|
+
|
|
271
|
+
return _hints(suite)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
CORPUS_SUFFIXES: dict[str, tuple[str, ...]] = {
|
|
275
|
+
"classification": (
|
|
276
|
+
"accuracy",
|
|
277
|
+
"precision",
|
|
278
|
+
"recall",
|
|
279
|
+
"f1",
|
|
280
|
+
"macro_f1",
|
|
281
|
+
"macro_recall",
|
|
282
|
+
"micro_f1",
|
|
283
|
+
"weighted_f1",
|
|
284
|
+
"confusion_matrix",
|
|
285
|
+
),
|
|
286
|
+
"ranking": ("precision_at_k", "recall_at_k", "ndcg_at_k", "mrr", "map"),
|
|
287
|
+
"calibration": ("ece", "brier"),
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _check_metric_collisions(suite: Suite, path: Path) -> None:
|
|
292
|
+
"""Refuse a suite where two evaluators would write the same metric key.
|
|
293
|
+
|
|
294
|
+
A corpus evaluator named `intent` emits `intent_accuracy`. If another evaluator
|
|
295
|
+
is *called* `intent_accuracy`, both write one key and the reported value depends
|
|
296
|
+
on ordering — which is exactly the kind of invisible nondeterminism that makes a
|
|
297
|
+
number untrustworthy.
|
|
298
|
+
"""
|
|
299
|
+
owners: dict[str, str] = {}
|
|
300
|
+
for evaluator in suite.evaluators:
|
|
301
|
+
emitted = {evaluator.name}
|
|
302
|
+
for suffix in CORPUS_SUFFIXES.get(evaluator.type, ()):
|
|
303
|
+
emitted.add(f"{evaluator.name}_{suffix}")
|
|
304
|
+
|
|
305
|
+
for key in emitted:
|
|
306
|
+
if key in owners and owners[key] != evaluator.name:
|
|
307
|
+
msg = (
|
|
308
|
+
f"{path}: evaluators {owners[key]!r} and {evaluator.name!r} both produce "
|
|
309
|
+
f"the metric {key!r}. Rename one — otherwise the reported value depends "
|
|
310
|
+
"on evaluation order."
|
|
311
|
+
)
|
|
312
|
+
raise SuiteError(msg)
|
|
313
|
+
owners[key] = evaluator.name
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _declared_metrics(suite: Suite) -> set[str]:
|
|
317
|
+
"""Metric keys the suite's evaluators can emit.
|
|
318
|
+
|
|
319
|
+
Corpus evaluators emit prefixed families (`classification_macro_f1`), so the
|
|
320
|
+
prefix is registered and gate matching allows a suffix.
|
|
321
|
+
"""
|
|
322
|
+
produced: set[str] = set()
|
|
323
|
+
for evaluator in suite.evaluators:
|
|
324
|
+
produced.add(evaluator.name)
|
|
325
|
+
if evaluator.type in ("classification", "ranking", "calibration"):
|
|
326
|
+
produced.add(evaluator.name)
|
|
327
|
+
if evaluator.type == "operational":
|
|
328
|
+
produced.update(
|
|
329
|
+
{
|
|
330
|
+
"total_cost",
|
|
331
|
+
"cost_per_example",
|
|
332
|
+
"judge_cost",
|
|
333
|
+
"total_tokens",
|
|
334
|
+
"error_rate",
|
|
335
|
+
"timeout_rate",
|
|
336
|
+
"retry_count",
|
|
337
|
+
"mean_latency_ms",
|
|
338
|
+
*(f"p{q}_latency_ms" for q in evaluator.percentiles),
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
return produced
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _hints(suite: Suite) -> list[str]:
|
|
345
|
+
hints: list[str] = []
|
|
346
|
+
|
|
347
|
+
if suite.judge_ratio > 0.6:
|
|
348
|
+
judges = sum(1 for e in suite.evaluators if e.type == "llm_judge")
|
|
349
|
+
hints.append(
|
|
350
|
+
f"{judges}/{len(suite.evaluators)} evaluators are LLM judges. Schema validity, "
|
|
351
|
+
"placeholders, length limits and tool ordering are all deterministic, free and "
|
|
352
|
+
"exactly reproducible — a judge-heavy suite is usually a modelling mistake."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
uncalibrated = [
|
|
356
|
+
e.name
|
|
357
|
+
for e in suite.evaluators
|
|
358
|
+
if e.type == "llm_judge" and e.calibration is None and _is_gated(suite, e.name)
|
|
359
|
+
]
|
|
360
|
+
if uncalibrated:
|
|
361
|
+
hints.append(
|
|
362
|
+
f"gating on uncalibrated judge(s): {', '.join(uncalibrated)}. An uncalibrated "
|
|
363
|
+
"judge is an unvalidated measuring instrument, and blocking a merge on one "
|
|
364
|
+
"means blocking engineers on a number nobody has checked."
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
if not any(g.blocking for g in suite.gates.values()) and suite.gates:
|
|
368
|
+
hints.append(
|
|
369
|
+
"no gate is blocking, so this suite can never fail CI. That may be "
|
|
370
|
+
"deliberate while you calibrate thresholds."
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
return hints
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _is_gated(suite: Suite, name: str) -> bool:
|
|
377
|
+
return any(key.split("[")[0] == name for key in suite.gates)
|