jevassert 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.
- jevassert/__init__.py +3 -0
- jevassert/__main__.py +3 -0
- jevassert/cli.py +529 -0
- jevassert/client.py +148 -0
- jevassert/compare.py +108 -0
- jevassert/metrics.py +526 -0
- jevassert/packs.py +454 -0
- jevassert/report.py +132 -0
- jevassert/runner.py +159 -0
- jevassert-0.1.0.dist-info/METADATA +194 -0
- jevassert-0.1.0.dist-info/RECORD +14 -0
- jevassert-0.1.0.dist-info/WHEEL +4 -0
- jevassert-0.1.0.dist-info/entry_points.txt +2 -0
- jevassert-0.1.0.dist-info/licenses/LICENSE +201 -0
jevassert/packs.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Pack spec v0 loader: question packs + labeled cases.
|
|
2
|
+
|
|
3
|
+
Canonical spec: the jev-packs repo `SPEC.md` (spec v0). This module implements
|
|
4
|
+
the loader contract; the jevassert CLI, jev-table and packs CI all import it.
|
|
5
|
+
|
|
6
|
+
Layout::
|
|
7
|
+
|
|
8
|
+
<pack>/
|
|
9
|
+
pack.yaml # metadata, state contract, questions, optional thresholds
|
|
10
|
+
cases.jsonl # golden cases, one JSON object per line
|
|
11
|
+
gates.yaml # optional — jevassert quality gates (format owned here)
|
|
12
|
+
|
|
13
|
+
``pack.yaml`` keys are exactly ``spec, id, version, license, tested,
|
|
14
|
+
description, state, questions, thresholds`` — unknown keys are rejected so typos
|
|
15
|
+
cannot pass silently. Questions use SPEC label shapes:
|
|
16
|
+
|
|
17
|
+
- ``noul``: ``{type, instructions}`` — boolean
|
|
18
|
+
- ``choice``: ``{type, instructions, options: {label: meaning}}``
|
|
19
|
+
- ``score``: ``{type, instructions, levels: [label, ...]}``
|
|
20
|
+
|
|
21
|
+
Every closed set must include the label ``unknown`` (Jev cannot abstain).
|
|
22
|
+
``score`` questions may add ``level_descriptions: {label: "situation text"}``
|
|
23
|
+
(SPEC v0 rule 5, adopted in jev-packs commit 1d7ae77): the loader sends the
|
|
24
|
+
description to the API instead of the bare label. When absent, labels are sent
|
|
25
|
+
as-is.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import re
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
import yaml
|
|
37
|
+
|
|
38
|
+
SPEC_VERSION = 0
|
|
39
|
+
QUESTION_TYPES = ("noul", "choice", "score")
|
|
40
|
+
GATE_KEYS = (
|
|
41
|
+
"min_accuracy",
|
|
42
|
+
"max_ece",
|
|
43
|
+
"max_cost_per_case_usd",
|
|
44
|
+
"max_p95_latency_ms",
|
|
45
|
+
"min_coverage_at_precision",
|
|
46
|
+
"min_accuracy_ci_lower",
|
|
47
|
+
"per_question",
|
|
48
|
+
)
|
|
49
|
+
PER_QUESTION_GATE_KEYS = ("min_accuracy", "max_ece")
|
|
50
|
+
ABSTAIN_LABEL = "unknown"
|
|
51
|
+
PACK_KEYS = {
|
|
52
|
+
"spec",
|
|
53
|
+
"id",
|
|
54
|
+
"version",
|
|
55
|
+
"license",
|
|
56
|
+
"tested",
|
|
57
|
+
"description",
|
|
58
|
+
"state",
|
|
59
|
+
"questions",
|
|
60
|
+
"thresholds",
|
|
61
|
+
}
|
|
62
|
+
ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
63
|
+
KEY_RE = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$")
|
|
64
|
+
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
|
65
|
+
TESTED_RE = re.compile(r"^jev-\d+\.\d+\.\d+$")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PackError(ValueError):
|
|
69
|
+
"""Invalid pack.yaml, cases.jsonl or gates.yaml."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Case:
|
|
74
|
+
id: str
|
|
75
|
+
state: dict[str, Any]
|
|
76
|
+
expect: dict[str, Any]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Pack:
|
|
81
|
+
path: Path
|
|
82
|
+
id: str
|
|
83
|
+
version: str
|
|
84
|
+
license: str
|
|
85
|
+
tested: str | None
|
|
86
|
+
description: str
|
|
87
|
+
state: dict[str, Any]
|
|
88
|
+
questions: dict[str, dict[str, Any]]
|
|
89
|
+
thresholds: dict[str, dict[str, float]]
|
|
90
|
+
gates: dict[str, Any]
|
|
91
|
+
cases: tuple[Case, ...]
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def record_model(self) -> str:
|
|
95
|
+
"""Model to send when recording: the verified version when pinned."""
|
|
96
|
+
return self.tested or "jev-latest"
|
|
97
|
+
|
|
98
|
+
def to_api_questions(self) -> dict[str, dict[str, Any]]:
|
|
99
|
+
"""Map SPEC questions to the System One API ``questions`` shape."""
|
|
100
|
+
api: dict[str, dict[str, Any]] = {}
|
|
101
|
+
for qid, question in self.questions.items():
|
|
102
|
+
qtype = question["type"]
|
|
103
|
+
if qtype == "noul":
|
|
104
|
+
api[qid] = {"type": "noul", "instructions": question["instructions"]}
|
|
105
|
+
elif qtype == "choice":
|
|
106
|
+
api[qid] = {
|
|
107
|
+
"type": "choice",
|
|
108
|
+
"instructions": question["instructions"],
|
|
109
|
+
"criteria": dict(question["options"]),
|
|
110
|
+
}
|
|
111
|
+
else:
|
|
112
|
+
descriptions = question.get("level_descriptions") or {}
|
|
113
|
+
api[qid] = {
|
|
114
|
+
"type": "score",
|
|
115
|
+
"instructions": question["instructions"],
|
|
116
|
+
"criteria": [descriptions.get(level, level) for level in question["levels"]],
|
|
117
|
+
}
|
|
118
|
+
return api
|
|
119
|
+
|
|
120
|
+
def labels(self, qid: str) -> set[str]:
|
|
121
|
+
"""Valid answer labels for a question, normalized to strings."""
|
|
122
|
+
question = self.questions[qid]
|
|
123
|
+
if question["type"] == "noul":
|
|
124
|
+
return {"true", "false"}
|
|
125
|
+
if question["type"] == "choice":
|
|
126
|
+
return set(question["options"])
|
|
127
|
+
return set(question["levels"])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_pack(path: str | Path, *, require_cases: bool = True) -> Pack:
|
|
131
|
+
"""Load and validate a pack directory (or a direct path to pack.yaml).
|
|
132
|
+
|
|
133
|
+
Registry packs must carry golden cases (SPEC.md); consumers that classify
|
|
134
|
+
unlabeled data (jev-table column specs) may pass ``require_cases=False``.
|
|
135
|
+
"""
|
|
136
|
+
path = Path(path)
|
|
137
|
+
pack_file = path / "pack.yaml" if path.is_dir() else path
|
|
138
|
+
pack_dir = pack_file.parent
|
|
139
|
+
if not pack_file.is_file():
|
|
140
|
+
raise PackError(f"pack file not found: {pack_file}")
|
|
141
|
+
try:
|
|
142
|
+
raw = yaml.safe_load(pack_file.read_text(encoding="utf-8"))
|
|
143
|
+
except yaml.YAMLError as exc: # pragma: no cover - message passthrough
|
|
144
|
+
raise PackError(f"{pack_file}: invalid YAML: {exc}") from exc
|
|
145
|
+
if not isinstance(raw, dict):
|
|
146
|
+
raise PackError(f"{pack_file}: top level must be a map")
|
|
147
|
+
|
|
148
|
+
_check_keys(pack_file, raw, required=PACK_KEYS - {"thresholds"}, allowed=set(PACK_KEYS))
|
|
149
|
+
|
|
150
|
+
if raw.get("spec") != SPEC_VERSION:
|
|
151
|
+
raise PackError(f"{pack_file}: spec must be {SPEC_VERSION}, got {raw.get('spec')!r}")
|
|
152
|
+
|
|
153
|
+
pack_id = _require_str(raw, "id", pack_file)
|
|
154
|
+
if not ID_RE.match(pack_id):
|
|
155
|
+
raise PackError(f"{pack_file}: id must be kebab-case, got {pack_id!r}")
|
|
156
|
+
if path.is_dir() and pack_id != pack_dir.name:
|
|
157
|
+
raise PackError(f"{pack_file}: id must equal the directory name ({pack_dir.name})")
|
|
158
|
+
version = _require_str(raw, "version", pack_file)
|
|
159
|
+
if not SEMVER_RE.match(version):
|
|
160
|
+
raise PackError(f"{pack_file}: version must be semver, got {version!r}")
|
|
161
|
+
license_id = _require_str(raw, "license", pack_file)
|
|
162
|
+
description = _require_str(raw, "description", pack_file)
|
|
163
|
+
|
|
164
|
+
tested = raw.get("tested")
|
|
165
|
+
if tested is not None and not (isinstance(tested, str) and TESTED_RE.match(tested)):
|
|
166
|
+
raise PackError(f"{pack_file}: tested must be null or 'jev-<semver>', got {tested!r}")
|
|
167
|
+
|
|
168
|
+
state = _validate_state(raw.get("state"), pack_file)
|
|
169
|
+
questions = _validate_questions(raw.get("questions"), pack_file)
|
|
170
|
+
thresholds = _validate_thresholds(raw.get("thresholds"), questions, pack_file)
|
|
171
|
+
gates = _load_gates(pack_dir, questions)
|
|
172
|
+
cases_file = pack_dir / "cases.jsonl"
|
|
173
|
+
cases = _load_cases(cases_file, state, questions, pack_file) if cases_file.is_file() else ()
|
|
174
|
+
if require_cases and not cases:
|
|
175
|
+
raise PackError(f"{pack_file}: cases.jsonl with at least one case is required")
|
|
176
|
+
|
|
177
|
+
return Pack(
|
|
178
|
+
path=pack_dir,
|
|
179
|
+
id=pack_id,
|
|
180
|
+
version=version,
|
|
181
|
+
license=license_id,
|
|
182
|
+
tested=tested,
|
|
183
|
+
description=description,
|
|
184
|
+
state=state,
|
|
185
|
+
questions=questions,
|
|
186
|
+
thresholds=thresholds,
|
|
187
|
+
gates=gates,
|
|
188
|
+
cases=cases,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _check_keys(
|
|
193
|
+
source: Path | str, obj: dict[str, Any], required: set[str], allowed: set[str]
|
|
194
|
+
) -> None:
|
|
195
|
+
for key in sorted(required - obj.keys()):
|
|
196
|
+
raise PackError(f"{source}: missing required key `{key}`")
|
|
197
|
+
for key in sorted(obj.keys() - allowed):
|
|
198
|
+
raise PackError(f"{source}: unexpected key `{key}` (typo? not part of spec v0)")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _require_str(raw: dict[str, Any], key: str, source: Path | str) -> str:
|
|
202
|
+
value = raw.get(key)
|
|
203
|
+
if not isinstance(value, str) or not value.strip():
|
|
204
|
+
raise PackError(f"{source}: {key} must be a non-empty string")
|
|
205
|
+
return value
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _validate_state(state: Any, source: Path) -> dict[str, Any]:
|
|
209
|
+
if not isinstance(state, dict):
|
|
210
|
+
raise PackError(f"{source}: state must be a map")
|
|
211
|
+
_check_keys(
|
|
212
|
+
source, state, required={"description", "fields"}, allowed={"description", "fields"}
|
|
213
|
+
)
|
|
214
|
+
description = state.get("description")
|
|
215
|
+
if not isinstance(description, str) or not description.strip():
|
|
216
|
+
raise PackError(f"{source}: state.description must be a non-empty string")
|
|
217
|
+
fields = state.get("fields")
|
|
218
|
+
if (
|
|
219
|
+
not isinstance(fields, list)
|
|
220
|
+
or not fields
|
|
221
|
+
or not all(isinstance(field, str) and KEY_RE.match(field) for field in fields)
|
|
222
|
+
):
|
|
223
|
+
raise PackError(f"{source}: state.fields must be snake_case field names")
|
|
224
|
+
if len(set(fields)) != len(fields):
|
|
225
|
+
raise PackError(f"{source}: state.fields contains duplicates")
|
|
226
|
+
return state
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _validate_questions(questions: Any, source: Path) -> dict[str, dict[str, Any]]:
|
|
230
|
+
if not isinstance(questions, dict) or not questions:
|
|
231
|
+
raise PackError(f"{source}: questions must be a non-empty map")
|
|
232
|
+
for qid, question in questions.items():
|
|
233
|
+
where = f"{source}: question '{qid}'"
|
|
234
|
+
if not KEY_RE.match(qid):
|
|
235
|
+
raise PackError(f"{where}: question id must be snake_case")
|
|
236
|
+
if not isinstance(question, dict):
|
|
237
|
+
raise PackError(f"{where} must be a map")
|
|
238
|
+
qtype = question.get("type")
|
|
239
|
+
if qtype not in QUESTION_TYPES:
|
|
240
|
+
raise PackError(f"{where}: type must be one of {', '.join(QUESTION_TYPES)}")
|
|
241
|
+
_require_str(question, "instructions", where)
|
|
242
|
+
|
|
243
|
+
if qtype == "noul":
|
|
244
|
+
_check_keys(
|
|
245
|
+
where, question, required={"type", "instructions"}, allowed={"type", "instructions"}
|
|
246
|
+
)
|
|
247
|
+
elif qtype == "choice":
|
|
248
|
+
_check_keys(
|
|
249
|
+
where,
|
|
250
|
+
question,
|
|
251
|
+
required={"type", "instructions", "options"},
|
|
252
|
+
allowed={"type", "instructions", "options"},
|
|
253
|
+
)
|
|
254
|
+
_validate_choice_options(question.get("options"), where)
|
|
255
|
+
else:
|
|
256
|
+
_check_keys(
|
|
257
|
+
where,
|
|
258
|
+
question,
|
|
259
|
+
required={"type", "instructions", "levels"},
|
|
260
|
+
allowed={"type", "instructions", "levels", "level_descriptions"},
|
|
261
|
+
)
|
|
262
|
+
_validate_score_levels(question, where)
|
|
263
|
+
return questions
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _validate_choice_options(options: Any, where: str) -> None:
|
|
267
|
+
if not isinstance(options, dict) or len(options) < 2:
|
|
268
|
+
raise PackError(f"{where}: choice options must be a map with at least 2 options")
|
|
269
|
+
for label, meaning in options.items():
|
|
270
|
+
if not isinstance(label, str) or not KEY_RE.match(label):
|
|
271
|
+
raise PackError(f"{where}: option key {label!r} must be snake_case")
|
|
272
|
+
if not isinstance(meaning, str) or not meaning.strip():
|
|
273
|
+
raise PackError(f"{where}: option `{label}` needs a one-line meaning")
|
|
274
|
+
if ABSTAIN_LABEL not in options:
|
|
275
|
+
raise PackError(
|
|
276
|
+
f"{where}: choice options must include `{ABSTAIN_LABEL}` (Jev cannot abstain)"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _validate_score_levels(question: dict[str, Any], where: str) -> None:
|
|
281
|
+
levels = question.get("levels")
|
|
282
|
+
if (
|
|
283
|
+
not isinstance(levels, list)
|
|
284
|
+
or not 2 <= len(levels) <= 10
|
|
285
|
+
or not all(isinstance(level, str) and KEY_RE.match(level) for level in levels)
|
|
286
|
+
):
|
|
287
|
+
raise PackError(f"{where}: score levels must be 2-10 snake_case labels")
|
|
288
|
+
if ABSTAIN_LABEL not in levels:
|
|
289
|
+
raise PackError(
|
|
290
|
+
f"{where}: score levels must include `{ABSTAIN_LABEL}` (Jev cannot abstain)"
|
|
291
|
+
)
|
|
292
|
+
descriptions = question.get("level_descriptions")
|
|
293
|
+
if descriptions is not None:
|
|
294
|
+
if not isinstance(descriptions, dict):
|
|
295
|
+
raise PackError(f"{where}: level_descriptions must be a map of level -> text")
|
|
296
|
+
for level, text in descriptions.items():
|
|
297
|
+
if level not in levels:
|
|
298
|
+
raise PackError(f"{where}: level_descriptions key '{level}' is not a level")
|
|
299
|
+
if not isinstance(text, str) or not text.strip():
|
|
300
|
+
raise PackError(
|
|
301
|
+
f"{where}: level_descriptions['{level}'] must be a non-empty string"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _validate_thresholds(
|
|
306
|
+
thresholds: Any, questions: dict[str, dict[str, Any]], source: Path
|
|
307
|
+
) -> dict[str, dict[str, float]]:
|
|
308
|
+
if thresholds is None:
|
|
309
|
+
return {}
|
|
310
|
+
if not isinstance(thresholds, dict):
|
|
311
|
+
raise PackError(f"{source}: thresholds must be a map")
|
|
312
|
+
normalized: dict[str, dict[str, float]] = {}
|
|
313
|
+
for qid, floors in thresholds.items():
|
|
314
|
+
where = f"{source}: thresholds.{qid}"
|
|
315
|
+
if qid not in questions:
|
|
316
|
+
raise PackError(f"{where}: unknown question")
|
|
317
|
+
if not isinstance(floors, dict) or not floors:
|
|
318
|
+
raise PackError(f"{where}: must map label -> probability")
|
|
319
|
+
valid_labels = _labels_for(questions[qid])
|
|
320
|
+
normalized[qid] = {}
|
|
321
|
+
for label, probability in floors.items():
|
|
322
|
+
key = _normalize_label(label)
|
|
323
|
+
if key not in valid_labels:
|
|
324
|
+
raise PackError(f"{where}: label {label!r} is not a valid answer")
|
|
325
|
+
if isinstance(probability, bool) or not isinstance(probability, int | float):
|
|
326
|
+
raise PackError(f"{where}: threshold for {label!r} must be a number in (0, 1]")
|
|
327
|
+
value = float(probability)
|
|
328
|
+
if not 0 < value <= 1:
|
|
329
|
+
raise PackError(f"{where}: threshold for {label!r} must be in (0, 1]")
|
|
330
|
+
normalized[qid][key] = value
|
|
331
|
+
return normalized
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _labels_for(question: dict[str, Any]) -> set[str]:
|
|
335
|
+
if question["type"] == "noul":
|
|
336
|
+
return {"true", "false"}
|
|
337
|
+
if question["type"] == "choice":
|
|
338
|
+
return set(question["options"])
|
|
339
|
+
return set(question["levels"])
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _normalize_label(label: Any) -> str:
|
|
343
|
+
if label is True:
|
|
344
|
+
return "true"
|
|
345
|
+
if label is False:
|
|
346
|
+
return "false"
|
|
347
|
+
return str(label)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _load_gates(pack_dir: Path, questions: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
|
351
|
+
"""Load optional gates.yaml (jevassert's own file, absent from SPEC pack.yaml)."""
|
|
352
|
+
gates_file = pack_dir / "gates.yaml"
|
|
353
|
+
if not gates_file.is_file():
|
|
354
|
+
return {}
|
|
355
|
+
try:
|
|
356
|
+
raw = yaml.safe_load(gates_file.read_text(encoding="utf-8"))
|
|
357
|
+
except yaml.YAMLError as exc: # pragma: no cover
|
|
358
|
+
raise PackError(f"{gates_file}: invalid YAML: {exc}") from exc
|
|
359
|
+
if raw is None:
|
|
360
|
+
return {}
|
|
361
|
+
if not isinstance(raw, dict):
|
|
362
|
+
raise PackError(f"{gates_file}: top level must be a map")
|
|
363
|
+
unknown = set(raw) - set(GATE_KEYS)
|
|
364
|
+
if unknown:
|
|
365
|
+
raise PackError(f"{gates_file}: unknown gate(s): {', '.join(sorted(unknown))}")
|
|
366
|
+
for key, value in raw.items():
|
|
367
|
+
if key == "min_coverage_at_precision":
|
|
368
|
+
if not isinstance(value, dict) or not {"precision", "min_coverage"} <= set(value):
|
|
369
|
+
raise PackError(
|
|
370
|
+
f"{gates_file}: min_coverage_at_precision takes {{precision, min_coverage}}"
|
|
371
|
+
)
|
|
372
|
+
elif key == "per_question":
|
|
373
|
+
if not isinstance(value, dict) or not value:
|
|
374
|
+
raise PackError(f"{gates_file}: per_question must map question id -> gates")
|
|
375
|
+
for qid, per_question in value.items():
|
|
376
|
+
where = f"{gates_file}: per_question.{qid}"
|
|
377
|
+
if qid not in questions:
|
|
378
|
+
raise PackError(f"{where}: unknown question")
|
|
379
|
+
if not isinstance(per_question, dict) or not per_question:
|
|
380
|
+
raise PackError(f"{where}: must map gate name -> value")
|
|
381
|
+
wrong = set(per_question) - set(PER_QUESTION_GATE_KEYS)
|
|
382
|
+
if wrong:
|
|
383
|
+
raise PackError(f"{where}: unknown gate(s): {', '.join(sorted(wrong))}")
|
|
384
|
+
for gate_value in per_question.values():
|
|
385
|
+
if isinstance(gate_value, bool) or not isinstance(gate_value, int | float):
|
|
386
|
+
raise PackError(f"{where}: gate values must be numbers")
|
|
387
|
+
elif isinstance(value, bool) or not isinstance(value, int | float):
|
|
388
|
+
raise PackError(f"{gates_file}: gate {key} must be a number")
|
|
389
|
+
return raw
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _load_cases(
|
|
393
|
+
cases_file: Path,
|
|
394
|
+
state: dict[str, Any],
|
|
395
|
+
questions: dict[str, dict[str, Any]],
|
|
396
|
+
source: Path,
|
|
397
|
+
) -> tuple[Case, ...]:
|
|
398
|
+
if not cases_file.is_file():
|
|
399
|
+
raise PackError(f"{source}: cases.jsonl is required next to pack.yaml")
|
|
400
|
+
fields = list(state["fields"])
|
|
401
|
+
cases: list[Case] = []
|
|
402
|
+
seen: set[str] = set()
|
|
403
|
+
for lineno, line in enumerate(cases_file.read_text(encoding="utf-8").splitlines(), 1):
|
|
404
|
+
line = line.strip()
|
|
405
|
+
if not line:
|
|
406
|
+
raise PackError(f"{cases_file}:{lineno}: blank line")
|
|
407
|
+
try:
|
|
408
|
+
raw = json.loads(line)
|
|
409
|
+
except json.JSONDecodeError as exc:
|
|
410
|
+
raise PackError(f"{cases_file}:{lineno}: invalid JSON: {exc}") from exc
|
|
411
|
+
where = f"{cases_file}:{lineno}"
|
|
412
|
+
if not isinstance(raw, dict):
|
|
413
|
+
raise PackError(f"{where}: case must be an object")
|
|
414
|
+
_check_keys(
|
|
415
|
+
where, raw, required={"id", "state", "expect"}, allowed={"id", "state", "expect"}
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
case_id = raw.get("id")
|
|
419
|
+
if not isinstance(case_id, str) or not case_id:
|
|
420
|
+
raise PackError(f"{where}: id must be a non-empty string")
|
|
421
|
+
if case_id in seen:
|
|
422
|
+
raise PackError(f"{where}: duplicate case id '{case_id}'")
|
|
423
|
+
seen.add(case_id)
|
|
424
|
+
|
|
425
|
+
case_state = raw.get("state")
|
|
426
|
+
if not isinstance(case_state, dict) or set(case_state) != set(fields):
|
|
427
|
+
raise PackError(f"{where}: state keys must be exactly {sorted(fields)}")
|
|
428
|
+
|
|
429
|
+
expect = raw.get("expect")
|
|
430
|
+
if not isinstance(expect, dict) or set(expect) != set(questions):
|
|
431
|
+
raise PackError(f"{where}: expect keys must be exactly {sorted(questions)}")
|
|
432
|
+
for qid, label in expect.items():
|
|
433
|
+
_validate_expectation(label, questions[qid], qid, where)
|
|
434
|
+
|
|
435
|
+
cases.append(Case(id=case_id, state=case_state, expect=expect))
|
|
436
|
+
return tuple(cases)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _validate_expectation(label: Any, question: dict[str, Any], qid: str, where: str) -> None:
|
|
440
|
+
qtype = question["type"]
|
|
441
|
+
if qtype == "noul":
|
|
442
|
+
if not isinstance(label, bool):
|
|
443
|
+
raise PackError(f"{where}: `{qid}` expects true/false")
|
|
444
|
+
elif qtype == "choice":
|
|
445
|
+
if not isinstance(label, str) or label not in question["options"]:
|
|
446
|
+
raise PackError(f"{where}: `{qid}` expects one of {sorted(question['options'])}")
|
|
447
|
+
else:
|
|
448
|
+
if not isinstance(label, str) or label not in question["levels"]:
|
|
449
|
+
raise PackError(f"{where}: `{qid}` expects one of {question['levels']}")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def label_index(question: dict[str, Any], label: str) -> int:
|
|
453
|
+
"""Index of a level label inside a score question (order = API order)."""
|
|
454
|
+
return list(question["levels"]).index(label)
|
jevassert/report.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Render check results as markdown (for pack evidence) and JUnit XML (for CI)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from xml.sax.saxutils import escape, quoteattr
|
|
6
|
+
|
|
7
|
+
from .metrics import GateResult, OverallMetrics, QuestionMetrics, ThresholdSuggestion
|
|
8
|
+
from .packs import Pack
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_markdown(
|
|
12
|
+
pack: Pack,
|
|
13
|
+
overall: OverallMetrics,
|
|
14
|
+
gates: list[GateResult],
|
|
15
|
+
suggestion: ThresholdSuggestion | None = None,
|
|
16
|
+
target_precision: float | None = None,
|
|
17
|
+
) -> str:
|
|
18
|
+
lines: list[str] = []
|
|
19
|
+
lines.append(f"# jevassert report — {pack.id} v{pack.version}")
|
|
20
|
+
lines.append("")
|
|
21
|
+
lines.append(
|
|
22
|
+
f"- model: `{pack.record_model}`"
|
|
23
|
+
+ (
|
|
24
|
+
f" (recorded against `{pack.tested}`)"
|
|
25
|
+
if pack.tested
|
|
26
|
+
else " (provisional — no pinned version)"
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
lines.append(
|
|
30
|
+
f"- cases: {overall.n_cases} ({overall.case_errors} errors, "
|
|
31
|
+
f"{overall.missing_items} missing answers)"
|
|
32
|
+
)
|
|
33
|
+
lines.append(
|
|
34
|
+
f"- items: {overall.n_items} — accuracy **{overall.accuracy:.3f}**, "
|
|
35
|
+
f"ECE **{overall.ece:.3f}**"
|
|
36
|
+
)
|
|
37
|
+
if overall.accuracy_ci is not None:
|
|
38
|
+
low, high = overall.accuracy_ci
|
|
39
|
+
ece_part = ""
|
|
40
|
+
if overall.ece_ci is not None:
|
|
41
|
+
ece_part = f", ECE CI {overall.ece_ci[0]:.3f}–{overall.ece_ci[1]:.3f}"
|
|
42
|
+
lines.append(f"- bootstrap 95%: accuracy CI {low:.3f}–{high:.3f}{ece_part}")
|
|
43
|
+
if overall.cost_per_case_usd is not None:
|
|
44
|
+
lines.append(
|
|
45
|
+
f"- cost: ${overall.cost_per_case_usd:.6f}/case (${overall.total_cost_usd:.4f} total)"
|
|
46
|
+
)
|
|
47
|
+
if overall.p95_latency_ms is not None:
|
|
48
|
+
lines.append(
|
|
49
|
+
f"- latency: p50 {overall.p50_latency_ms:.0f}ms, p95 {overall.p95_latency_ms:.0f}ms"
|
|
50
|
+
)
|
|
51
|
+
lines.append("")
|
|
52
|
+
lines.append("## Per question")
|
|
53
|
+
lines.append("")
|
|
54
|
+
lines.append("| question | type | n | missing | accuracy | mean p(decision) | ECE | Brier |")
|
|
55
|
+
lines.append("|---|---|---|---|---|---|---|---|")
|
|
56
|
+
for metrics in overall.per_question.values():
|
|
57
|
+
lines.append(_question_row(metrics))
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append("## Coverage at decision probability")
|
|
60
|
+
lines.append("")
|
|
61
|
+
lines.append("| accept if p >= | coverage | precision |")
|
|
62
|
+
lines.append("|---|---|---|")
|
|
63
|
+
for row in overall.coverage:
|
|
64
|
+
precision = f"{row.precision:.3f}" if row.precision is not None else "—"
|
|
65
|
+
lines.append(f"| {row.threshold:.2f} | {row.coverage:.3f} | {precision} |")
|
|
66
|
+
if overall.threshold_coverage is not None:
|
|
67
|
+
tc = overall.threshold_coverage
|
|
68
|
+
lines.append("")
|
|
69
|
+
lines.append("## Author thresholds (pack threshold floors)")
|
|
70
|
+
lines.append("")
|
|
71
|
+
precision = f"{tc.precision:.3f}" if tc.precision is not None else "—"
|
|
72
|
+
lines.append(
|
|
73
|
+
f"- auto-accepted {tc.n_auto}/{tc.n_total} ({tc.coverage:.3f}), precision {precision}"
|
|
74
|
+
)
|
|
75
|
+
lines.append("- labels without a floor (usually `unknown`) always route to review")
|
|
76
|
+
if suggestion is not None and target_precision is not None:
|
|
77
|
+
lines.append("")
|
|
78
|
+
lines.append(f"## Suggested threshold (target precision {target_precision:.2f})")
|
|
79
|
+
lines.append("")
|
|
80
|
+
lines.append(
|
|
81
|
+
f"- accept when p >= {suggestion.threshold:.2f}: coverage {suggestion.coverage:.3f}, "
|
|
82
|
+
f"precision {suggestion.precision:.3f} (n={suggestion.n_accepted})"
|
|
83
|
+
)
|
|
84
|
+
lines.append("")
|
|
85
|
+
lines.append("## Gates")
|
|
86
|
+
lines.append("")
|
|
87
|
+
if not gates:
|
|
88
|
+
lines.append("No gates declared in gates.yaml.")
|
|
89
|
+
for gate in gates:
|
|
90
|
+
mark = "PASS" if gate.ok else ("SKIP" if gate.ok is None else "FAIL")
|
|
91
|
+
lines.append(f"- **{mark}** `{gate.gate}` — {gate.detail}")
|
|
92
|
+
lines.append("")
|
|
93
|
+
return "\n".join(lines)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _question_row(metrics: QuestionMetrics) -> str:
|
|
97
|
+
brier = f"{metrics.brier:.3f}" if metrics.brier is not None else "—"
|
|
98
|
+
return (
|
|
99
|
+
f"| {metrics.qid} | {metrics.qtype} | {metrics.n} | {metrics.missing} "
|
|
100
|
+
f"| {metrics.accuracy:.3f} | {metrics.mean_decision_prob:.3f} "
|
|
101
|
+
f"| {metrics.ece:.3f} | {brier} |"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def render_junit(pack: Pack, gates: list[GateResult]) -> str:
|
|
106
|
+
classname = quoteattr(f"jevassert.{pack.id}")
|
|
107
|
+
cases = []
|
|
108
|
+
failures = 0
|
|
109
|
+
for gate in gates:
|
|
110
|
+
if gate.ok is False:
|
|
111
|
+
failures += 1
|
|
112
|
+
cases.append(
|
|
113
|
+
f" <testcase name={quoteattr(gate.gate)} classname={classname}>"
|
|
114
|
+
f"<failure message={quoteattr(gate.detail)}/></testcase>"
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
skipped = '<skipped message="not computable"/>' if gate.ok is None else ""
|
|
118
|
+
cases.append(
|
|
119
|
+
f" <testcase name={quoteattr(gate.gate)} classname={classname}>"
|
|
120
|
+
f"{skipped}</testcase>"
|
|
121
|
+
)
|
|
122
|
+
body = "\n".join(cases)
|
|
123
|
+
return (
|
|
124
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
125
|
+
f'<testsuite name={quoteattr(pack.id)} tests="{len(gates)}" failures="{failures}">\n'
|
|
126
|
+
f"{body}\n</testsuite>\n"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def escape_comment(text: str) -> str:
|
|
131
|
+
"""Escape text for embedding; exposed for tests."""
|
|
132
|
+
return escape(text)
|