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