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.
- quantdiff/__init__.py +53 -0
- quantdiff/__main__.py +5 -0
- quantdiff/_http.py +151 -0
- quantdiff/_text.py +13 -0
- quantdiff/_version.py +1 -0
- quantdiff/api.py +340 -0
- quantdiff/backends/__init__.py +28 -0
- quantdiff/backends/_common.py +342 -0
- quantdiff/backends/base.py +91 -0
- quantdiff/backends/llamacpp.py +428 -0
- quantdiff/backends/ollama.py +359 -0
- quantdiff/backends/openai_compat.py +338 -0
- quantdiff/cache.py +240 -0
- quantdiff/card.py +1664 -0
- quantdiff/cli.py +377 -0
- quantdiff/discover.py +488 -0
- quantdiff/errors.py +45 -0
- quantdiff/metrics/__init__.py +36 -0
- quantdiff/metrics/codeexec.py +428 -0
- quantdiff/metrics/jsonschema.py +610 -0
- quantdiff/metrics/logit.py +214 -0
- quantdiff/metrics/tasks.py +114 -0
- quantdiff/metrics/textsim.py +66 -0
- quantdiff/metrics/toolcheck.py +99 -0
- quantdiff/png.py +360 -0
- quantdiff/preflight.py +365 -0
- quantdiff/progress.py +283 -0
- quantdiff/py.typed +0 -0
- quantdiff/report.py +780 -0
- quantdiff/runner.py +492 -0
- quantdiff/spec.py +154 -0
- quantdiff/stats.py +226 -0
- quantdiff/suites/__init__.py +462 -0
- quantdiff/suites/data/chat.jsonl +22 -0
- quantdiff/suites/data/code.jsonl +32 -0
- quantdiff/suites/data/json.jsonl +34 -0
- quantdiff/suites/data/scoring.jsonl +41 -0
- quantdiff/suites/data/tools.jsonl +32 -0
- quantdiff/types.py +322 -0
- quantdiff/verdict.py +1513 -0
- quantdiff-0.1.0rc1.dist-info/METADATA +514 -0
- quantdiff-0.1.0rc1.dist-info/RECORD +45 -0
- quantdiff-0.1.0rc1.dist-info/WHEEL +4 -0
- quantdiff-0.1.0rc1.dist-info/entry_points.txt +2 -0
- 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
|