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,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)