quantdiff 0.1.0rc1__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.
Files changed (45) hide show
  1. quantdiff/__init__.py +53 -0
  2. quantdiff/__main__.py +5 -0
  3. quantdiff/_http.py +151 -0
  4. quantdiff/_text.py +13 -0
  5. quantdiff/_version.py +1 -0
  6. quantdiff/api.py +340 -0
  7. quantdiff/backends/__init__.py +28 -0
  8. quantdiff/backends/_common.py +342 -0
  9. quantdiff/backends/base.py +91 -0
  10. quantdiff/backends/llamacpp.py +428 -0
  11. quantdiff/backends/ollama.py +359 -0
  12. quantdiff/backends/openai_compat.py +338 -0
  13. quantdiff/cache.py +240 -0
  14. quantdiff/card.py +1664 -0
  15. quantdiff/cli.py +377 -0
  16. quantdiff/discover.py +488 -0
  17. quantdiff/errors.py +45 -0
  18. quantdiff/metrics/__init__.py +36 -0
  19. quantdiff/metrics/codeexec.py +428 -0
  20. quantdiff/metrics/jsonschema.py +610 -0
  21. quantdiff/metrics/logit.py +214 -0
  22. quantdiff/metrics/tasks.py +114 -0
  23. quantdiff/metrics/textsim.py +66 -0
  24. quantdiff/metrics/toolcheck.py +99 -0
  25. quantdiff/png.py +360 -0
  26. quantdiff/preflight.py +365 -0
  27. quantdiff/progress.py +283 -0
  28. quantdiff/py.typed +0 -0
  29. quantdiff/report.py +780 -0
  30. quantdiff/runner.py +492 -0
  31. quantdiff/spec.py +154 -0
  32. quantdiff/stats.py +226 -0
  33. quantdiff/suites/__init__.py +462 -0
  34. quantdiff/suites/data/chat.jsonl +22 -0
  35. quantdiff/suites/data/code.jsonl +32 -0
  36. quantdiff/suites/data/json.jsonl +34 -0
  37. quantdiff/suites/data/scoring.jsonl +41 -0
  38. quantdiff/suites/data/tools.jsonl +32 -0
  39. quantdiff/types.py +322 -0
  40. quantdiff/verdict.py +1513 -0
  41. quantdiff-0.1.0rc1.dist-info/METADATA +514 -0
  42. quantdiff-0.1.0rc1.dist-info/RECORD +45 -0
  43. quantdiff-0.1.0rc1.dist-info/WHEEL +4 -0
  44. quantdiff-0.1.0rc1.dist-info/entry_points.txt +2 -0
  45. quantdiff-0.1.0rc1.dist-info/licenses/LICENSE +202 -0
quantdiff/report.py ADDED
@@ -0,0 +1,780 @@
1
+ """Report files, candidate ranking, the one-line verdict, and methodology notes.
2
+
3
+ Reports are plain JSON so they can be diffed, archived, and re-rendered later. Loading
4
+ is strict: every field is checked by name and type, unknown fields are rejected, and
5
+ every problem raises ReportError with the path of the offending field. Version 1 reports
6
+ still load: the per-prompt and per-case fields added in version 2 default to empty, so
7
+ they render, but the verdict has nothing to pair and says less.
8
+
9
+ Ranking and the verdict itself live in quantdiff.verdict; this module adapts them for
10
+ callers that want a ranked list or a single line of text.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import json
17
+ import math
18
+ import os
19
+ import tempfile
20
+ from collections.abc import Callable, Sequence
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Final, TypeVar, get_args
24
+
25
+ from quantdiff.errors import ReportError
26
+ from quantdiff.types import (
27
+ AgreementMetrics,
28
+ BackendKind,
29
+ CandidateResult,
30
+ CandidateSpec,
31
+ CaseOutcome,
32
+ JSONValue,
33
+ LogitMetrics,
34
+ PerfMetrics,
35
+ PerfSource,
36
+ PreflightFinding,
37
+ PromptLogit,
38
+ Report,
39
+ RunSettings,
40
+ ServerInfo,
41
+ Severity,
42
+ TaskKind,
43
+ TaskMetrics,
44
+ TemplateDialect,
45
+ )
46
+ from quantdiff.verdict import SCORED_TASK_KINDS, judge, kld_thresholds
47
+
48
+ __all__ = [
49
+ "MAX_REPORT_BYTES",
50
+ "SCHEMA_VERSION",
51
+ "SCORED_TASK_KINDS",
52
+ "RankedCandidate",
53
+ "format_kld",
54
+ "format_rate",
55
+ "format_top1",
56
+ "load_report",
57
+ "notes_for",
58
+ "rank_candidates",
59
+ "report_from_dict",
60
+ "report_to_dict",
61
+ "save_report",
62
+ "task_for",
63
+ "text_forced_note",
64
+ "verdict",
65
+ "verdict_sentences",
66
+ ]
67
+
68
+ SCHEMA_VERSION: Final = 2
69
+ READABLE_SCHEMA_VERSIONS: Final = (1, 2)
70
+ MAX_REPORT_BYTES: Final = 50 * 1024 * 1024
71
+
72
+ _BACKEND_KINDS: Final[tuple[BackendKind, ...]] = get_args(BackendKind)
73
+ _TASK_KINDS: Final[tuple[TaskKind, ...]] = get_args(TaskKind)
74
+ _SEVERITIES: Final[tuple[Severity, ...]] = get_args(Severity)
75
+ _DIALECTS: Final[tuple[TemplateDialect, ...]] = get_args(TemplateDialect)
76
+ _PERF_SOURCES: Final[tuple[PerfSource, ...]] = get_args(PerfSource)
77
+ _JSON_TYPE_NAMES: Final[dict[type, str]] = {
78
+ type(None): "null",
79
+ bool: "boolean",
80
+ int: "integer",
81
+ float: "number",
82
+ str: "string",
83
+ list: "array",
84
+ dict: "object",
85
+ }
86
+
87
+ _T = TypeVar("_T")
88
+ _S = TypeVar("_S", bound=str)
89
+
90
+
91
+ # Formatting -------------------------------------------------------------------------------
92
+
93
+
94
+ def format_rate(value: float) -> str:
95
+ """Format a task pass rate as a whole percentage, such as "88%"."""
96
+ return f"{value * 100:.0f}%"
97
+
98
+
99
+ def format_top1(value: float) -> str:
100
+ """Format top-1 agreement with one decimal, such as "97.1%"."""
101
+ return f"{value * 100:.1f}%"
102
+
103
+
104
+ def format_kld(value: float) -> str:
105
+ """Format a KL divergence with three significant digits, such as "0.0123"."""
106
+ return f"{value:.3g}"
107
+
108
+
109
+ # Serialization ----------------------------------------------------------------------------
110
+
111
+
112
+ def report_to_dict(report: Report) -> dict[str, JSONValue]:
113
+ """Convert `report` to JSON-compatible data at SCHEMA_VERSION. Inverse of report_from_dict."""
114
+ return {
115
+ "schema_version": SCHEMA_VERSION,
116
+ "quantdiff_version": report.quantdiff_version,
117
+ "created_at": report.created_at,
118
+ "title": report.title,
119
+ "settings": _settings_to_dict(report.settings),
120
+ "reference": _candidate_to_dict(report.reference),
121
+ "candidates": [_candidate_to_dict(candidate) for candidate in report.candidates],
122
+ "notes": list(report.notes),
123
+ }
124
+
125
+
126
+ def _settings_to_dict(settings: RunSettings) -> dict[str, JSONValue]:
127
+ return {
128
+ "suites": list(settings.suites),
129
+ "top_k": settings.top_k,
130
+ "score_tokens": settings.score_tokens,
131
+ "allow_code_exec": settings.allow_code_exec,
132
+ "seed": settings.seed,
133
+ "prompts_file": settings.prompts_file,
134
+ "longest_prompt_tokens": settings.longest_prompt_tokens,
135
+ "max_size_bytes": settings.max_size_bytes,
136
+ }
137
+
138
+
139
+ def _candidate_to_dict(result: CandidateResult) -> dict[str, JSONValue]:
140
+ spec = result.spec
141
+ return {
142
+ "spec": {
143
+ "kind": spec.kind,
144
+ "base_url": spec.base_url,
145
+ "model": spec.model,
146
+ "label": spec.label,
147
+ "api_key_env": spec.api_key_env,
148
+ },
149
+ "info": None if result.info is None else _info_to_dict(result.info),
150
+ "logit": None if result.logit is None else _logit_to_dict(result.logit),
151
+ "tasks": [_task_to_dict(task) for task in result.tasks],
152
+ "agreement": None if result.agreement is None else _agreement_to_dict(result.agreement),
153
+ "perf": None if result.perf is None else _perf_to_dict(result.perf),
154
+ "preflight": [_finding_to_dict(finding) for finding in result.preflight],
155
+ "errors": list(result.errors),
156
+ "outcomes": [_outcome_to_dict(outcome) for outcome in result.outcomes],
157
+ }
158
+
159
+
160
+ def _info_to_dict(info: ServerInfo) -> dict[str, JSONValue]:
161
+ return {
162
+ "backend": info.backend,
163
+ "model": info.model,
164
+ "context_length": info.context_length,
165
+ "chat_template": info.chat_template,
166
+ "template_dialect": info.template_dialect,
167
+ "supports_logprobs": info.supports_logprobs,
168
+ "exact_token_ids": info.exact_token_ids,
169
+ "details": [[key, value] for key, value in info.details],
170
+ "size_bytes": info.size_bytes,
171
+ "weights_id": info.weights_id,
172
+ }
173
+
174
+
175
+ def _logit_to_dict(logit: LogitMetrics) -> dict[str, JSONValue]:
176
+ return {
177
+ "prompts": logit.prompts,
178
+ "positions": logit.positions,
179
+ "top1_agreement": logit.top1_agreement,
180
+ "kld_mean": logit.kld_mean,
181
+ "kld_p99": logit.kld_p99,
182
+ "kld_max": logit.kld_max,
183
+ "exact_token_ids": logit.exact_token_ids,
184
+ "per_prompt": [
185
+ {
186
+ "prompt_id": prompt.prompt_id,
187
+ "positions": prompt.positions,
188
+ "top1_matches": prompt.top1_matches,
189
+ "kld_mean": prompt.kld_mean,
190
+ }
191
+ for prompt in logit.per_prompt
192
+ ],
193
+ }
194
+
195
+
196
+ def _task_to_dict(task: TaskMetrics) -> dict[str, JSONValue]:
197
+ return {
198
+ "kind": task.kind,
199
+ "total": task.total,
200
+ "passed": task.passed,
201
+ "skipped": task.skipped,
202
+ "rate": task.rate,
203
+ "failures": [_outcome_to_dict(outcome) for outcome in task.failures],
204
+ }
205
+
206
+
207
+ def _outcome_to_dict(outcome: CaseOutcome) -> dict[str, JSONValue]:
208
+ return {
209
+ "case_id": outcome.case_id,
210
+ "kind": outcome.kind,
211
+ "passed": outcome.passed,
212
+ "reason": outcome.reason,
213
+ }
214
+
215
+
216
+ def _agreement_to_dict(agreement: AgreementMetrics) -> dict[str, JSONValue]:
217
+ return {
218
+ "cases": agreement.cases,
219
+ "exact_match_rate": agreement.exact_match_rate,
220
+ "mean_similarity": agreement.mean_similarity,
221
+ "per_case": [
222
+ {"case_id": case_id, "similarity": similarity}
223
+ for case_id, similarity in agreement.per_case
224
+ ],
225
+ }
226
+
227
+
228
+ def _perf_to_dict(perf: PerfMetrics) -> dict[str, JSONValue]:
229
+ return {
230
+ "tokens_per_second": perf.tokens_per_second,
231
+ "mean_latency_seconds": perf.mean_latency_seconds,
232
+ "source": perf.source,
233
+ }
234
+
235
+
236
+ def _finding_to_dict(finding: PreflightFinding) -> dict[str, JSONValue]:
237
+ return {
238
+ "check": finding.check,
239
+ "severity": finding.severity,
240
+ "message": finding.message,
241
+ "fix": finding.fix,
242
+ }
243
+
244
+
245
+ def report_from_dict(data: object) -> Report:
246
+ """Build a Report from parsed JSON, raising ReportError on any schema violation.
247
+
248
+ Accepts every version in READABLE_SCHEMA_VERSIONS. Each version must have exactly its
249
+ own fields, and the result is always a current-version Report.
250
+ """
251
+ fields = _object(
252
+ data,
253
+ "",
254
+ required=(
255
+ "schema_version",
256
+ "quantdiff_version",
257
+ "created_at",
258
+ "title",
259
+ "settings",
260
+ "reference",
261
+ "candidates",
262
+ "notes",
263
+ ),
264
+ )
265
+ version = fields["schema_version"]
266
+ if isinstance(version, bool) or version not in READABLE_SCHEMA_VERSIONS:
267
+ readable = " and ".join(str(v) for v in READABLE_SCHEMA_VERSIONS)
268
+ raise ReportError(
269
+ f"schema_version: unsupported value {version!r}; this quantdiff reads "
270
+ f"versions {readable}"
271
+ )
272
+ reader = _Reader(version=_int(version, "schema_version"))
273
+ return Report(
274
+ schema_version=SCHEMA_VERSION,
275
+ quantdiff_version=_str(fields["quantdiff_version"], "quantdiff_version"),
276
+ created_at=_str(fields["created_at"], "created_at"),
277
+ title=_str(fields["title"], "title"),
278
+ settings=reader.settings(fields["settings"], "settings"),
279
+ reference=reader.candidate(fields["reference"], "reference"),
280
+ candidates=_tuple_of(fields["candidates"], "candidates", reader.candidate),
281
+ notes=_tuple_of(fields["notes"], "notes", _str),
282
+ )
283
+
284
+
285
+ @dataclass(frozen=True, slots=True)
286
+ class _Reader:
287
+ """Readers for the parts of a report whose fields depend on the schema version."""
288
+
289
+ version: int
290
+
291
+ def _fields(self, v1: Sequence[str], added: Sequence[str] = ()) -> tuple[str, ...]:
292
+ return (*v1, *added) if self.version >= 2 else tuple(v1)
293
+
294
+ def settings(self, value: object, path: str) -> RunSettings:
295
+ fields = _object(
296
+ value,
297
+ path,
298
+ required=self._fields(
299
+ ("suites", "top_k", "score_tokens", "allow_code_exec", "seed", "prompts_file"),
300
+ ("longest_prompt_tokens",),
301
+ ),
302
+ # Added within schema version 2; reports written before it simply lack the field.
303
+ optional=self._fields((), ("max_size_bytes",)),
304
+ )
305
+ return RunSettings(
306
+ suites=_tuple_of(fields["suites"], f"{path}.suites", _str),
307
+ top_k=_int(fields["top_k"], f"{path}.top_k"),
308
+ score_tokens=_int(fields["score_tokens"], f"{path}.score_tokens"),
309
+ allow_code_exec=_bool(fields["allow_code_exec"], f"{path}.allow_code_exec"),
310
+ seed=_int(fields["seed"], f"{path}.seed", minimum=None),
311
+ prompts_file=_optional(fields["prompts_file"], f"{path}.prompts_file", _str),
312
+ longest_prompt_tokens=_optional(
313
+ fields.get("longest_prompt_tokens"), f"{path}.longest_prompt_tokens", _int
314
+ ),
315
+ max_size_bytes=_optional(fields.get("max_size_bytes"), f"{path}.max_size_bytes", _int),
316
+ )
317
+
318
+ def candidate(self, value: object, path: str) -> CandidateResult:
319
+ fields = _object(
320
+ value,
321
+ path,
322
+ required=self._fields(
323
+ ("spec", "info", "logit", "tasks", "agreement", "perf", "preflight", "errors"),
324
+ ("outcomes",),
325
+ ),
326
+ )
327
+ return CandidateResult(
328
+ spec=_spec_from(fields["spec"], f"{path}.spec"),
329
+ info=_optional(fields["info"], f"{path}.info", self.info),
330
+ logit=_optional(fields["logit"], f"{path}.logit", self.logit),
331
+ tasks=_tuple_of(fields["tasks"], f"{path}.tasks", _task_from),
332
+ agreement=_optional(fields["agreement"], f"{path}.agreement", self.agreement),
333
+ perf=_optional(fields["perf"], f"{path}.perf", self.perf),
334
+ preflight=_tuple_of(fields["preflight"], f"{path}.preflight", _finding_from),
335
+ errors=_tuple_of(fields["errors"], f"{path}.errors", _str),
336
+ outcomes=_tuple_of(fields.get("outcomes", []), f"{path}.outcomes", _outcome_from),
337
+ )
338
+
339
+ def info(self, value: object, path: str) -> ServerInfo:
340
+ fields = _object(
341
+ value,
342
+ path,
343
+ required=self._fields(
344
+ (
345
+ "backend",
346
+ "model",
347
+ "context_length",
348
+ "chat_template",
349
+ "template_dialect",
350
+ "supports_logprobs",
351
+ "exact_token_ids",
352
+ "details",
353
+ ),
354
+ ("size_bytes", "weights_id"),
355
+ ),
356
+ )
357
+ return ServerInfo(
358
+ backend=_choice(fields["backend"], f"{path}.backend", _BACKEND_KINDS),
359
+ model=_str(fields["model"], f"{path}.model"),
360
+ context_length=_optional(fields["context_length"], f"{path}.context_length", _int),
361
+ chat_template=_optional(fields["chat_template"], f"{path}.chat_template", _str),
362
+ template_dialect=_choice(
363
+ fields["template_dialect"], f"{path}.template_dialect", _DIALECTS
364
+ ),
365
+ supports_logprobs=_bool(fields["supports_logprobs"], f"{path}.supports_logprobs"),
366
+ exact_token_ids=_bool(fields["exact_token_ids"], f"{path}.exact_token_ids"),
367
+ details=_tuple_of(fields["details"], f"{path}.details", _detail_from),
368
+ size_bytes=_optional(fields.get("size_bytes"), f"{path}.size_bytes", _int),
369
+ weights_id=_optional(fields.get("weights_id"), f"{path}.weights_id", _str),
370
+ )
371
+
372
+ def logit(self, value: object, path: str) -> LogitMetrics:
373
+ fields = _object(
374
+ value,
375
+ path,
376
+ required=self._fields(
377
+ (
378
+ "prompts",
379
+ "positions",
380
+ "top1_agreement",
381
+ "kld_mean",
382
+ "kld_p99",
383
+ "kld_max",
384
+ "exact_token_ids",
385
+ ),
386
+ ("per_prompt",),
387
+ ),
388
+ )
389
+ return LogitMetrics(
390
+ prompts=_int(fields["prompts"], f"{path}.prompts"),
391
+ positions=_int(fields["positions"], f"{path}.positions"),
392
+ top1_agreement=_fraction(fields["top1_agreement"], f"{path}.top1_agreement"),
393
+ kld_mean=_float(fields["kld_mean"], f"{path}.kld_mean"),
394
+ kld_p99=_float(fields["kld_p99"], f"{path}.kld_p99"),
395
+ kld_max=_float(fields["kld_max"], f"{path}.kld_max"),
396
+ exact_token_ids=_bool(fields["exact_token_ids"], f"{path}.exact_token_ids"),
397
+ per_prompt=_tuple_of(fields.get("per_prompt", []), f"{path}.per_prompt", _prompt_from),
398
+ )
399
+
400
+ def agreement(self, value: object, path: str) -> AgreementMetrics:
401
+ fields = _object(
402
+ value,
403
+ path,
404
+ required=self._fields(("cases", "exact_match_rate", "mean_similarity"), ("per_case",)),
405
+ )
406
+ return AgreementMetrics(
407
+ cases=_int(fields["cases"], f"{path}.cases"),
408
+ exact_match_rate=_fraction(fields["exact_match_rate"], f"{path}.exact_match_rate"),
409
+ mean_similarity=_fraction(fields["mean_similarity"], f"{path}.mean_similarity"),
410
+ per_case=_tuple_of(fields.get("per_case", []), f"{path}.per_case", _similarity_from),
411
+ )
412
+
413
+ def perf(self, value: object, path: str) -> PerfMetrics:
414
+ fields = _object(
415
+ value,
416
+ path,
417
+ required=self._fields(("tokens_per_second", "mean_latency_seconds"), ("source",)),
418
+ )
419
+ return PerfMetrics(
420
+ tokens_per_second=_optional(
421
+ fields["tokens_per_second"], f"{path}.tokens_per_second", _float
422
+ ),
423
+ mean_latency_seconds=_optional(
424
+ fields["mean_latency_seconds"], f"{path}.mean_latency_seconds", _float
425
+ ),
426
+ source=_choice(fields.get("source", "wall_clock"), f"{path}.source", _PERF_SOURCES),
427
+ )
428
+
429
+
430
+ def _spec_from(value: object, path: str) -> CandidateSpec:
431
+ fields = _object(value, path, required=("kind", "base_url", "model", "label", "api_key_env"))
432
+ return CandidateSpec(
433
+ kind=_choice(fields["kind"], f"{path}.kind", _BACKEND_KINDS),
434
+ base_url=_str(fields["base_url"], f"{path}.base_url"),
435
+ model=_str(fields["model"], f"{path}.model"),
436
+ label=_str(fields["label"], f"{path}.label"),
437
+ api_key_env=_optional(fields["api_key_env"], f"{path}.api_key_env", _str),
438
+ )
439
+
440
+
441
+ def _detail_from(value: object, path: str) -> tuple[str, str]:
442
+ if not isinstance(value, list) or len(value) != 2:
443
+ raise ReportError(f"{path}: expected a [key, value] pair of strings")
444
+ return _str(value[0], f"{path}[0]"), _str(value[1], f"{path}[1]")
445
+
446
+
447
+ def _prompt_from(value: object, path: str) -> PromptLogit:
448
+ fields = _object(value, path, required=("prompt_id", "positions", "top1_matches", "kld_mean"))
449
+ prompt = PromptLogit(
450
+ prompt_id=_str(fields["prompt_id"], f"{path}.prompt_id"),
451
+ positions=_int(fields["positions"], f"{path}.positions"),
452
+ top1_matches=_int(fields["top1_matches"], f"{path}.top1_matches"),
453
+ kld_mean=_optional(fields["kld_mean"], f"{path}.kld_mean", _float),
454
+ )
455
+ if prompt.top1_matches > prompt.positions:
456
+ raise ReportError(f"{path}: top1_matches exceeds positions")
457
+ return prompt
458
+
459
+
460
+ def _similarity_from(value: object, path: str) -> tuple[str, float]:
461
+ fields = _object(value, path, required=("case_id", "similarity"))
462
+ return (
463
+ _str(fields["case_id"], f"{path}.case_id"),
464
+ _fraction(fields["similarity"], f"{path}.similarity"),
465
+ )
466
+
467
+
468
+ def _task_from(value: object, path: str) -> TaskMetrics:
469
+ # "rate" is derived from the counts on output; it is accepted and ignored on input.
470
+ fields = _object(
471
+ value,
472
+ path,
473
+ required=("kind", "total", "passed", "skipped", "failures"),
474
+ ignored=("rate",),
475
+ )
476
+ task = TaskMetrics(
477
+ kind=_choice(fields["kind"], f"{path}.kind", _TASK_KINDS),
478
+ total=_int(fields["total"], f"{path}.total"),
479
+ passed=_int(fields["passed"], f"{path}.passed"),
480
+ skipped=_int(fields["skipped"], f"{path}.skipped"),
481
+ failures=_tuple_of(fields["failures"], f"{path}.failures", _outcome_from),
482
+ )
483
+ if task.passed + task.skipped > task.total:
484
+ raise ReportError(f"{path}: passed + skipped exceeds total")
485
+ return task
486
+
487
+
488
+ def _outcome_from(value: object, path: str) -> CaseOutcome:
489
+ fields = _object(value, path, required=("case_id", "kind", "passed", "reason"))
490
+ return CaseOutcome(
491
+ case_id=_str(fields["case_id"], f"{path}.case_id"),
492
+ kind=_choice(fields["kind"], f"{path}.kind", _TASK_KINDS),
493
+ passed=_optional(fields["passed"], f"{path}.passed", _bool),
494
+ reason=_str(fields["reason"], f"{path}.reason"),
495
+ )
496
+
497
+
498
+ def _finding_from(value: object, path: str) -> PreflightFinding:
499
+ fields = _object(value, path, required=("check", "severity", "message", "fix"))
500
+ return PreflightFinding(
501
+ check=_str(fields["check"], f"{path}.check"),
502
+ severity=_choice(fields["severity"], f"{path}.severity", _SEVERITIES),
503
+ message=_str(fields["message"], f"{path}.message"),
504
+ fix=_optional(fields["fix"], f"{path}.fix", _str),
505
+ )
506
+
507
+
508
+ # Strict field readers ---------------------------------------------------------------------
509
+
510
+
511
+ def _where(path: str) -> str:
512
+ return path or "report"
513
+
514
+
515
+ def _object(
516
+ value: object,
517
+ path: str,
518
+ *,
519
+ required: Sequence[str],
520
+ optional: Sequence[str] = (),
521
+ ignored: Sequence[str] = (),
522
+ ) -> dict[str, object]:
523
+ if not isinstance(value, dict):
524
+ raise ReportError(f"{_where(path)}: expected an object, got {_type_name(value)}")
525
+ allowed = set(required) | set(optional) | set(ignored)
526
+ unknown = sorted(str(key) for key in value if key not in allowed)
527
+ if unknown:
528
+ raise ReportError(f"{_where(path)}: unknown field {unknown[0]!r}")
529
+ for key in required:
530
+ if key not in value:
531
+ prefix = f"{path}." if path else ""
532
+ raise ReportError(f"{prefix}{key}: missing required field")
533
+ return value
534
+
535
+
536
+ def _str(value: object, path: str) -> str:
537
+ if not isinstance(value, str):
538
+ raise ReportError(f"{path}: expected a string, got {_type_name(value)}")
539
+ return value
540
+
541
+
542
+ def _bool(value: object, path: str) -> bool:
543
+ if not isinstance(value, bool):
544
+ raise ReportError(f"{path}: expected true or false, got {_type_name(value)}")
545
+ return value
546
+
547
+
548
+ def _int(value: object, path: str, *, minimum: int | None = 0) -> int:
549
+ if isinstance(value, bool) or not isinstance(value, int):
550
+ raise ReportError(f"{path}: expected an integer, got {_type_name(value)}")
551
+ if minimum is not None and value < minimum:
552
+ raise ReportError(f"{path}: must be at least {minimum}, got {value}")
553
+ return value
554
+
555
+
556
+ def _float(value: object, path: str) -> float:
557
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
558
+ raise ReportError(f"{path}: expected a number, got {_type_name(value)}")
559
+ if not math.isfinite(value):
560
+ raise ReportError(f"{path}: must be a finite number")
561
+ return float(value)
562
+
563
+
564
+ def _fraction(value: object, path: str) -> float:
565
+ number = _float(value, path)
566
+ if not 0.0 <= number <= 1.0:
567
+ raise ReportError(f"{path}: must be between 0 and 1, got {number}")
568
+ return number
569
+
570
+
571
+ def _choice(value: object, path: str, options: tuple[_S, ...]) -> _S:
572
+ for option in options:
573
+ if value == option:
574
+ return option
575
+ allowed = ", ".join(options)
576
+ raise ReportError(f"{path}: expected one of {allowed}, got {value!r}")
577
+
578
+
579
+ def _optional(value: object, path: str, read: Callable[[object, str], _T]) -> _T | None:
580
+ return None if value is None else read(value, path)
581
+
582
+
583
+ def _tuple_of(value: object, path: str, read: Callable[[object, str], _T]) -> tuple[_T, ...]:
584
+ if not isinstance(value, list):
585
+ raise ReportError(f"{path}: expected an array, got {_type_name(value)}")
586
+ return tuple(read(item, f"{path}[{index}]") for index, item in enumerate(value))
587
+
588
+
589
+ def _type_name(value: object) -> str:
590
+ return _JSON_TYPE_NAMES.get(type(value), type(value).__name__)
591
+
592
+
593
+ # Files ------------------------------------------------------------------------------------
594
+
595
+
596
+ def save_report(report: Report, path: str | os.PathLike[str]) -> None:
597
+ """Write `report` as UTF-8 JSON. The write is atomic: readers never see a partial file."""
598
+ target = Path(path)
599
+ try:
600
+ text = json.dumps(
601
+ report_to_dict(report), indent=2, sort_keys=True, ensure_ascii=False, allow_nan=False
602
+ )
603
+ except ValueError as exc:
604
+ raise ReportError(f"report contains a value JSON cannot represent: {exc}") from exc
605
+ try:
606
+ _write_atomically(target, text + "\n")
607
+ except OSError as exc:
608
+ raise ReportError(f"cannot write report to {target}: {exc.strerror or exc}") from exc
609
+
610
+
611
+ def _write_atomically(target: Path, text: str) -> None:
612
+ descriptor, temp_name = tempfile.mkstemp(
613
+ dir=target.parent, prefix=f".{target.name}.", suffix=".tmp"
614
+ )
615
+ temp_path = Path(temp_name)
616
+ try:
617
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
618
+ handle.write(text)
619
+ handle.flush()
620
+ os.fsync(handle.fileno())
621
+ temp_path.replace(target)
622
+ except BaseException:
623
+ with contextlib.suppress(OSError):
624
+ temp_path.unlink()
625
+ raise
626
+
627
+
628
+ def load_report(path: str | os.PathLike[str], *, max_bytes: int = MAX_REPORT_BYTES) -> Report:
629
+ """Read and validate a report file written by save_report."""
630
+ source = Path(path)
631
+ try:
632
+ with source.open("rb") as handle:
633
+ raw = handle.read(max_bytes + 1)
634
+ except OSError as exc:
635
+ raise ReportError(f"cannot read report {source}: {exc.strerror or exc}") from exc
636
+ if len(raw) > max_bytes:
637
+ raise ReportError(f"report {source} is larger than {max_bytes} bytes")
638
+ try:
639
+ data = json.loads(raw.decode("utf-8"), parse_constant=_reject_constant)
640
+ except UnicodeDecodeError as exc:
641
+ raise ReportError(f"report {source} is not valid UTF-8") from exc
642
+ except json.JSONDecodeError as exc:
643
+ raise ReportError(
644
+ f"report {source} is not valid JSON: {exc.msg} at line {exc.lineno}"
645
+ ) from exc
646
+ except RecursionError as exc:
647
+ raise ReportError(f"report {source} is nested too deeply") from exc
648
+ return report_from_dict(data)
649
+
650
+
651
+ def _reject_constant(name: str) -> object:
652
+ raise ReportError(f"report contains {name}, which is not valid JSON")
653
+
654
+
655
+ # Ranking ----------------------------------------------------------------------------------
656
+
657
+
658
+ @dataclass(frozen=True, slots=True)
659
+ class RankedCandidate:
660
+ """A candidate with its position on the scorecard and the numbers that placed it."""
661
+
662
+ result: CandidateResult
663
+ rank: int
664
+ task_rate: float | None
665
+ """Mean pass rate over json, tools, and code suites that scored at least one case."""
666
+ top1: float | None
667
+ kld_mean: float | None
668
+
669
+ @property
670
+ def has_metrics(self) -> bool:
671
+ return self.task_rate is not None or self.top1 is not None or self.kld_mean is not None
672
+
673
+
674
+ def task_for(result: CandidateResult, kind: TaskKind) -> TaskMetrics | None:
675
+ """Return the metrics for one task kind, or None if that suite did not run."""
676
+ return next((task for task in result.tasks if task.kind == kind), None)
677
+
678
+
679
+ def rank_candidates(report: Report) -> tuple[RankedCandidate, ...]:
680
+ """Order candidates best first, in the order the verdict gives them.
681
+
682
+ The verdict ranks by status first (recommended, ok, inconclusive, avoid, failed), then
683
+ by mean KLD, top-1 agreement, mean pass rate, and size; see quantdiff.verdict. Ranks run
684
+ 1..n over every candidate, failed ones included, so a list position is always a rank.
685
+ """
686
+ by_label = {result.spec.label: result for result in report.candidates}
687
+ ordered = [by_label[call.label] for call in judge(report).candidates]
688
+ return tuple(
689
+ RankedCandidate(
690
+ result=result,
691
+ rank=rank,
692
+ task_rate=_mean_task_rate(result),
693
+ top1=None if result.logit is None else result.logit.top1_agreement,
694
+ kld_mean=None if result.logit is None else result.logit.kld_mean,
695
+ )
696
+ for rank, result in enumerate(ordered, start=1)
697
+ )
698
+
699
+
700
+ def _mean_task_rate(result: CandidateResult) -> float | None:
701
+ rates = [
702
+ rate
703
+ for task in result.tasks
704
+ if task.kind in SCORED_TASK_KINDS and (rate := task.rate) is not None
705
+ ]
706
+ return sum(rates) / len(rates) if rates else None
707
+
708
+
709
+ # Verdict ----------------------------------------------------------------------------------
710
+
711
+
712
+ def verdict(report: Report) -> str:
713
+ """The verdict on one line: the headline followed by its supporting sentences."""
714
+ return " ".join(verdict_sentences(report))
715
+
716
+
717
+ def verdict_sentences(report: Report) -> tuple[str, ...]:
718
+ """The verdict as separate sentences, headline first, so a card can emphasize it."""
719
+ result = judge(report)
720
+ return (result.headline, *result.details)
721
+
722
+
723
+ # Methodology notes ------------------------------------------------------------------------
724
+
725
+
726
+ def notes_for(report: Report) -> tuple[str, ...]:
727
+ """Footnotes that explain how to read the numbers on this particular scorecard."""
728
+ notes = [
729
+ "T1 metrics (top-1, KLD) compare next-token probabilities and need logprobs.",
730
+ "T2 metrics (pass rates, chat agreement) score real answers on any server.",
731
+ ]
732
+ if any(result.logit is not None for result in report.candidates):
733
+ bars = kld_thresholds(report.settings.top_k)
734
+ notes.append(
735
+ f"KLD bands: under {bars.near_lossless:g} near-lossless, under {bars.close:g} small, "
736
+ f"under {bars.large:g} moderate, above that large. KLD here is a lower bound "
737
+ f"computed from the top {report.settings.top_k} tokens, measured against your "
738
+ "reference."
739
+ )
740
+ retokenized = [
741
+ result
742
+ for result in report.candidates
743
+ if result.logit is not None and not result.logit.exact_token_ids
744
+ ]
745
+ if retokenized:
746
+ ollama = all(result.spec.kind == "ollama" for result in retokenized)
747
+ labels = [result.spec.label for result in retokenized]
748
+ notes.append(text_forced_note(labels, ollama=ollama))
749
+ if report.candidates and _mean_task_rate(report.reference) is not None:
750
+ notes.append(
751
+ "The ref row is the reference on the same cases. Signed numbers are differences "
752
+ "from it in percentage points."
753
+ )
754
+ if any(result.agreement is not None for result in report.candidates):
755
+ notes.append("Agree is how similar chat answers are to the reference's answers.")
756
+ results = (report.reference, *report.candidates)
757
+ if not report.settings.allow_code_exec and any(_skipped_code(r) for r in results):
758
+ notes.append("Code cases were skipped because code execution was not enabled.")
759
+ notes.extend(report.notes)
760
+ return tuple(notes)
761
+
762
+
763
+ def text_forced_note(labels: Sequence[str], *, ollama: bool) -> str:
764
+ """The note for logit metrics measured by forcing text rather than token ids: "Ollama"
765
+ when every such candidate is an Ollama model, otherwise the labels by name."""
766
+ if ollama:
767
+ return (
768
+ "Ollama logit metrics are text-forced; on English text they matched "
769
+ "llama-server's exact token-id forcing (docs/calibration.md). Non-Latin text may "
770
+ "read higher."
771
+ )
772
+ return (
773
+ f"Logit metrics for {', '.join(labels)} are text-forced; on English text this matched "
774
+ "exact token-id forcing (docs/calibration.md). Non-Latin text may read higher."
775
+ )
776
+
777
+
778
+ def _skipped_code(result: CandidateResult) -> bool:
779
+ task = task_for(result, "code")
780
+ return task is not None and task.skipped > 0