ocr-foundry 0.2.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.
- khocr_gen/__init__.py +11 -0
- khocr_gen/_pil_compat.py +25 -0
- khocr_gen/_rust_accel.py +204 -0
- khocr_gen/augmentation.py +1193 -0
- khocr_gen/benchmark.py +888 -0
- khocr_gen/benchmark_cmd.py +103 -0
- khocr_gen/cli.py +246 -0
- khocr_gen/combine.py +214 -0
- khocr_gen/combine_cmd.py +125 -0
- khocr_gen/config.py +1474 -0
- khocr_gen/config_loader.py +157 -0
- khocr_gen/corpus.py +621 -0
- khocr_gen/data_generator.py +1160 -0
- khocr_gen/errors.py +33 -0
- khocr_gen/evaluate_cmd.py +50 -0
- khocr_gen/evaluation.py +259 -0
- khocr_gen/fonts.py +316 -0
- khocr_gen/generate.py +227 -0
- khocr_gen/layout.py +1028 -0
- khocr_gen/layout_cmd.py +371 -0
- khocr_gen/layout_degradation.py +128 -0
- khocr_gen/layout_mesh.py +200 -0
- khocr_gen/layout_shaping.py +264 -0
- khocr_gen/layout_transform.py +238 -0
- khocr_gen/layout_warp.py +473 -0
- khocr_gen/line_height.py +127 -0
- khocr_gen/lmdb_pack.py +132 -0
- khocr_gen/logging.py +25 -0
- khocr_gen/normalizer.py +221 -0
- khocr_gen/parallel.py +366 -0
- khocr_gen/provenance.py +328 -0
- khocr_gen/rendering.py +1607 -0
- khocr_gen/text_support.py +429 -0
- khocr_gen/verify.py +471 -0
- khocr_gen/viewer.py +172 -0
- ocr_foundry-0.2.0.dist-info/METADATA +174 -0
- ocr_foundry-0.2.0.dist-info/RECORD +40 -0
- ocr_foundry-0.2.0.dist-info/WHEEL +4 -0
- ocr_foundry-0.2.0.dist-info/entry_points.txt +3 -0
- ocr_foundry-0.2.0.dist-info/licenses/LICENSE +21 -0
khocr_gen/benchmark.py
ADDED
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
"""Frozen, verifiable OCR benchmark suites around the offline scorer.
|
|
2
|
+
|
|
3
|
+
See docs/EVALUATION.md ("Frozen benchmark suites"). Stdlib only: this module hashes
|
|
4
|
+
supplied bytes, validates their relationships, and scores supplied prediction files
|
|
5
|
+
with ``evaluation.score_records``. It never runs OCR, decodes images, downloads data
|
|
6
|
+
or fabricates results. Source and crop files are treated as opaque bytes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import platform
|
|
14
|
+
import re
|
|
15
|
+
import unicodedata
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .evaluation import score_records
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
import os
|
|
25
|
+
from collections.abc import Mapping, Sequence
|
|
26
|
+
|
|
27
|
+
SCHEMA_VERSION = 1
|
|
28
|
+
LOCK_KIND = "khocr-gen.benchmark.lock"
|
|
29
|
+
DEFAULT_LOCK = "benchmark.lock.json"
|
|
30
|
+
SPLITS = ("train", "dev", "test")
|
|
31
|
+
ROLES = ("baseline", "recipe", "ablation")
|
|
32
|
+
DATA_KINDS = ("fixture", "real_document")
|
|
33
|
+
STRATA = ("language", "script", "domain", "resolution")
|
|
34
|
+
METADATA_KEYS = ("model", "checkpoint", "training", "recipe", "inference", "seed", "environment")
|
|
35
|
+
STATUSES = ("ok", "failed")
|
|
36
|
+
_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")
|
|
37
|
+
_EVIDENCE_NOTE = {
|
|
38
|
+
"fixture": "fixture data: exercises the harness and is not a real-document OCR result",
|
|
39
|
+
"real_document": "scores apply only to the frozen reference crops of this release",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------------- parsing
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _no_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
47
|
+
result: dict[str, Any] = {}
|
|
48
|
+
for key, value in pairs:
|
|
49
|
+
if key in result:
|
|
50
|
+
raise ValueError(f"duplicate JSON key {key!r}")
|
|
51
|
+
result[key] = value
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _invalid_constant(value: str) -> None:
|
|
56
|
+
raise ValueError(f"invalid JSON constant {value}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _loads(text: str, context: str) -> Any:
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(text, object_pairs_hook=_no_duplicates, parse_constant=_invalid_constant)
|
|
62
|
+
except (ValueError, RecursionError) as exc:
|
|
63
|
+
raise ValueError(f"{context}: invalid JSON: {exc}") from exc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _string(value: object, field: str, context: str, *, nonempty: bool = True) -> str:
|
|
67
|
+
if not isinstance(value, str) or (nonempty and not value.strip()):
|
|
68
|
+
qualifier = "nonempty " if nonempty else ""
|
|
69
|
+
raise ValueError(f"{context}: {field} must be a {qualifier}string")
|
|
70
|
+
try:
|
|
71
|
+
value.encode("utf-8")
|
|
72
|
+
except UnicodeEncodeError as exc:
|
|
73
|
+
raise ValueError(f"{context}: {field} contains an unpaired Unicode surrogate") from exc
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _fields(
|
|
78
|
+
value: object, context: str, required: tuple[str, ...], optional: tuple[str, ...] = ()
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
if not isinstance(value, dict):
|
|
81
|
+
raise ValueError(f"{context}: must be a JSON object")
|
|
82
|
+
typed_value: dict[str, Any] = {k: v for k, v in value.items() if isinstance(k, str)}
|
|
83
|
+
if len(typed_value) != len(value):
|
|
84
|
+
raise ValueError(f"{context}: must be a JSON object")
|
|
85
|
+
missing = [key for key in required if key not in typed_value]
|
|
86
|
+
unknown = sorted(set(typed_value) - set(required) - set(optional))
|
|
87
|
+
if missing or unknown:
|
|
88
|
+
raise ValueError(f"{context}: missing fields {missing!r}; unknown fields {unknown!r}")
|
|
89
|
+
return typed_value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _choice(value: object, field: str, context: str, choices: tuple[str, ...]) -> str:
|
|
93
|
+
if value not in choices:
|
|
94
|
+
raise ValueError(f"{context}: {field} must be one of {', '.join(choices)}")
|
|
95
|
+
return str(value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _name(value: object, field: str, context: str) -> str:
|
|
99
|
+
text = _string(value, field, context)
|
|
100
|
+
if not _NAME.match(text):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"{context}: {field} {text!r} must use only letters, digits, '.', '_', '-'"
|
|
103
|
+
)
|
|
104
|
+
return text
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _rel_path(value: object, field: str, context: str) -> str:
|
|
108
|
+
"""Syntactically validate a manifest-root-relative POSIX path."""
|
|
109
|
+
text = _string(value, field, context)
|
|
110
|
+
parts = text.split("/")
|
|
111
|
+
if (
|
|
112
|
+
"\\" in text
|
|
113
|
+
or "\x00" in text
|
|
114
|
+
or PureWindowsPath(text).drive
|
|
115
|
+
or PurePosixPath(text).is_absolute()
|
|
116
|
+
or any(part in ("", ".", "..") for part in parts)
|
|
117
|
+
):
|
|
118
|
+
raise ValueError(f"{context}: {field} {text!r} must be a relative path inside the root")
|
|
119
|
+
return text
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _locate(root: Path, rel: str, context: str) -> Path:
|
|
123
|
+
"""Resolve *rel* below *root* (symlinks included) and require a regular file."""
|
|
124
|
+
path = root.joinpath(*rel.split("/")).resolve()
|
|
125
|
+
if not path.is_relative_to(root):
|
|
126
|
+
raise ValueError(f"{context}: {rel!r} escapes the suite root")
|
|
127
|
+
if not path.is_file():
|
|
128
|
+
raise ValueError(f"{context}: missing or non-regular file {rel!r}")
|
|
129
|
+
return path
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _sha256_bytes(data: bytes) -> str:
|
|
133
|
+
return hashlib.sha256(data).hexdigest()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _sha256_file(path: Path) -> str:
|
|
137
|
+
digest = hashlib.sha256()
|
|
138
|
+
with path.open("rb") as stream:
|
|
139
|
+
for chunk in iter(lambda: stream.read(1 << 20), b""):
|
|
140
|
+
digest.update(chunk)
|
|
141
|
+
return digest.hexdigest()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _jsonl(data: bytes, context: str) -> list[tuple[str, dict[str, Any]]]:
|
|
145
|
+
try:
|
|
146
|
+
text = data.decode("utf-8")
|
|
147
|
+
except UnicodeDecodeError as exc:
|
|
148
|
+
raise ValueError(f"{context}: invalid UTF-8: {exc}") from exc
|
|
149
|
+
lines = text.split("\n")
|
|
150
|
+
if lines and lines[-1] == "":
|
|
151
|
+
lines.pop()
|
|
152
|
+
records: list[tuple[str, dict[str, Any]]] = []
|
|
153
|
+
for number, line in enumerate(lines, 1):
|
|
154
|
+
where = f"{context}:{number}"
|
|
155
|
+
record = _loads(line, where)
|
|
156
|
+
if not isinstance(record, dict):
|
|
157
|
+
raise ValueError(f"{where}: each record must be a JSON object")
|
|
158
|
+
records.append((where, record))
|
|
159
|
+
if not records:
|
|
160
|
+
raise ValueError(f"{context}: empty corpus")
|
|
161
|
+
return records
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class Reference:
|
|
166
|
+
id: str
|
|
167
|
+
text: str
|
|
168
|
+
language: str
|
|
169
|
+
script: str
|
|
170
|
+
domain: str
|
|
171
|
+
document_id: str
|
|
172
|
+
crop_path: str
|
|
173
|
+
bounds: tuple[int, int, int, int]
|
|
174
|
+
resolution: str = "unknown"
|
|
175
|
+
|
|
176
|
+
def _parse_references(data: bytes, context: str) -> dict[str, Reference]:
|
|
177
|
+
references: dict[str, Reference] = {}
|
|
178
|
+
for where, record in _jsonl(data, context):
|
|
179
|
+
sample_id = _string(record.get("id"), "id", where)
|
|
180
|
+
if sample_id in references:
|
|
181
|
+
raise ValueError(f"{where}: duplicate id {sample_id!r}")
|
|
182
|
+
text = _string(record.get("text"), "text", where, nonempty=False)
|
|
183
|
+
metadata = record.get("metadata")
|
|
184
|
+
if not isinstance(metadata, dict):
|
|
185
|
+
raise ValueError(f"{where}: metadata must be a JSON object")
|
|
186
|
+
crop = metadata.get("crop")
|
|
187
|
+
if not isinstance(crop, dict):
|
|
188
|
+
raise ValueError(f"{where}: metadata.crop must be an object with path and bounds")
|
|
189
|
+
bounds = crop.get("bounds")
|
|
190
|
+
if (
|
|
191
|
+
not isinstance(bounds, list)
|
|
192
|
+
or len(bounds) != 4
|
|
193
|
+
or not all(isinstance(v, int) and not isinstance(v, bool) and v >= 0 for v in bounds)
|
|
194
|
+
or bounds[2] <= bounds[0]
|
|
195
|
+
or bounds[3] <= bounds[1]
|
|
196
|
+
):
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"{where}: metadata.crop.bounds must be [x0, y0, x1, y1] "
|
|
199
|
+
"nonnegative integers with x1 > x0 and y1 > y0"
|
|
200
|
+
)
|
|
201
|
+
res = metadata.get("resolution")
|
|
202
|
+
if res is None:
|
|
203
|
+
res = metadata.get("dpi")
|
|
204
|
+
res_str = str(res) if res is not None else "unknown"
|
|
205
|
+
references[sample_id] = Reference(
|
|
206
|
+
id=sample_id,
|
|
207
|
+
text=text,
|
|
208
|
+
language=_string(metadata.get("language"), "metadata.language", where),
|
|
209
|
+
script=_string(metadata.get("script"), "metadata.script", where),
|
|
210
|
+
domain=_string(metadata.get("domain"), "metadata.domain", where),
|
|
211
|
+
document_id=_string(metadata.get("document_id"), "metadata.document_id", where),
|
|
212
|
+
crop_path=_rel_path(crop.get("path"), "metadata.crop.path", where),
|
|
213
|
+
bounds=(bounds[0], bounds[1], bounds[2], bounds[3]),
|
|
214
|
+
resolution=res_str,
|
|
215
|
+
)
|
|
216
|
+
return references
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass(frozen=True)
|
|
220
|
+
class Prediction:
|
|
221
|
+
text: str
|
|
222
|
+
status: str
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_predictions(data: bytes, context: str) -> dict[str, Prediction]:
|
|
226
|
+
predictions: dict[str, Prediction] = {}
|
|
227
|
+
for where, record in _jsonl(data, context):
|
|
228
|
+
sample_id = _string(record.get("id"), "id", where)
|
|
229
|
+
if sample_id in predictions:
|
|
230
|
+
raise ValueError(f"{where}: duplicate id {sample_id!r}")
|
|
231
|
+
text = _string(record.get("text"), "text", where, nonempty=False)
|
|
232
|
+
status = _choice(record.get("status"), "status", where, STATUSES)
|
|
233
|
+
reason = record.get("reason")
|
|
234
|
+
if status == "failed":
|
|
235
|
+
_string(reason, "reason (required for failed rows)", where)
|
|
236
|
+
if text != "":
|
|
237
|
+
raise ValueError(f"{where}: failed rows must have empty text")
|
|
238
|
+
elif reason is not None:
|
|
239
|
+
raise ValueError(f"{where}: reason is only allowed for failed rows")
|
|
240
|
+
predictions[sample_id] = Prediction(text, status)
|
|
241
|
+
return predictions
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ----------------------------------------------------------------------- spec / lock
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_MAX_METADATA_DEPTH = 32
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _depth(value: object) -> int:
|
|
251
|
+
"""Nesting depth of JSON containers, computed iteratively."""
|
|
252
|
+
deepest = 0
|
|
253
|
+
stack = [(value, 1)]
|
|
254
|
+
while stack:
|
|
255
|
+
item, level = stack.pop()
|
|
256
|
+
if isinstance(item, dict | list):
|
|
257
|
+
deepest = max(deepest, level)
|
|
258
|
+
stack.extend(
|
|
259
|
+
(child, level + 1) for child in (item.values() if isinstance(item, dict) else item)
|
|
260
|
+
)
|
|
261
|
+
return deepest
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _canonical(value: object) -> bytes:
|
|
265
|
+
return json.dumps(
|
|
266
|
+
value, sort_keys=True, ensure_ascii=False, allow_nan=False, separators=(",", ":")
|
|
267
|
+
).encode("utf-8")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parse_spec(raw: object, context: str) -> dict[str, Any]:
|
|
271
|
+
"""Validate manifest-shaped input (also reconstructed from a lock) and sort it."""
|
|
272
|
+
spec = _fields(
|
|
273
|
+
raw,
|
|
274
|
+
context,
|
|
275
|
+
("schema_version", "release_id", "data_kind", "references", "documents", "runs"),
|
|
276
|
+
)
|
|
277
|
+
if spec["schema_version"] != SCHEMA_VERSION or isinstance(spec["schema_version"], bool):
|
|
278
|
+
raise ValueError(f"{context}: schema_version must be {SCHEMA_VERSION}")
|
|
279
|
+
_name(spec["release_id"], "release_id", context)
|
|
280
|
+
_choice(spec["data_kind"], "data_kind", context, DATA_KINDS)
|
|
281
|
+
_rel_path(spec["references"], "references", context)
|
|
282
|
+
documents, runs = spec["documents"], spec["runs"]
|
|
283
|
+
if not isinstance(documents, list) or not documents:
|
|
284
|
+
raise ValueError(f"{context}: documents must be a nonempty list")
|
|
285
|
+
if not isinstance(runs, list) or not runs:
|
|
286
|
+
raise ValueError(f"{context}: runs must be a nonempty list")
|
|
287
|
+
|
|
288
|
+
seen_ids: set[str] = set()
|
|
289
|
+
seen_paths: dict[str, str] = {}
|
|
290
|
+
group_split: dict[str, str] = {}
|
|
291
|
+
typed_documents: list[dict[str, Any]] = []
|
|
292
|
+
for index, doc in enumerate(documents):
|
|
293
|
+
where = f"{context}: documents[{index}]"
|
|
294
|
+
doc_fields = _fields(
|
|
295
|
+
doc, where, ("id", "source_group", "split", "source", "provenance", "rights")
|
|
296
|
+
)
|
|
297
|
+
doc_id = _string(doc_fields["id"], "id", where)
|
|
298
|
+
if doc_id in seen_ids:
|
|
299
|
+
raise ValueError(f"{where}: duplicate document id {doc_id!r}")
|
|
300
|
+
seen_ids.add(doc_id)
|
|
301
|
+
group = _string(doc_fields["source_group"], "source_group", where)
|
|
302
|
+
split = _choice(doc_fields["split"], "split", where, SPLITS)
|
|
303
|
+
if group_split.setdefault(group, split) != split:
|
|
304
|
+
raise ValueError(
|
|
305
|
+
f"{where}: source_group {group!r} is assigned to splits "
|
|
306
|
+
f"{group_split[group]!r} and {split!r}"
|
|
307
|
+
)
|
|
308
|
+
path = _rel_path(doc_fields["source"], "source", where)
|
|
309
|
+
if seen_paths.setdefault(path, doc_id) != doc_id:
|
|
310
|
+
raise ValueError(f"{where}: source {path!r} is shared with another document")
|
|
311
|
+
_string(doc_fields["provenance"], "provenance", where)
|
|
312
|
+
_string(doc_fields["rights"], "rights", where)
|
|
313
|
+
typed_documents.append(doc_fields)
|
|
314
|
+
|
|
315
|
+
run_names: set[str] = set()
|
|
316
|
+
run_paths: set[str] = set()
|
|
317
|
+
typed_runs: list[dict[str, Any]] = []
|
|
318
|
+
for index, run in enumerate(runs):
|
|
319
|
+
where = f"{context}: runs[{index}]"
|
|
320
|
+
run_fields = _fields(run, where, ("name", "role", "predictions", "metadata"))
|
|
321
|
+
name = _name(run_fields["name"], "name", where)
|
|
322
|
+
if name in run_names:
|
|
323
|
+
raise ValueError(f"{where}: duplicate run name {name!r}")
|
|
324
|
+
run_names.add(name)
|
|
325
|
+
_choice(run_fields["role"], "role", where, ROLES)
|
|
326
|
+
path = _rel_path(run_fields["predictions"], "predictions", where)
|
|
327
|
+
if path in run_paths:
|
|
328
|
+
raise ValueError(f"{where}: predictions {path!r} is shared with another run")
|
|
329
|
+
run_paths.add(path)
|
|
330
|
+
_fields(run_fields["metadata"], f"{where}: metadata", (), METADATA_KEYS)
|
|
331
|
+
if _depth(run_fields["metadata"]) > _MAX_METADATA_DEPTH:
|
|
332
|
+
raise ValueError(f"{where}: metadata nests deeper than {_MAX_METADATA_DEPTH} levels")
|
|
333
|
+
_canonical(run_fields["metadata"]) # rejects non-JSON-representable values early
|
|
334
|
+
typed_runs.append(run_fields)
|
|
335
|
+
return {
|
|
336
|
+
**spec,
|
|
337
|
+
"documents": sorted(typed_documents, key=lambda d: d["id"]),
|
|
338
|
+
"runs": sorted(typed_runs, key=lambda r: r["name"]),
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass
|
|
343
|
+
class Suite:
|
|
344
|
+
root: Path
|
|
345
|
+
lock: dict[str, Any]
|
|
346
|
+
references: dict[str, Reference]
|
|
347
|
+
document_split: dict[str, str]
|
|
348
|
+
predictions: dict[str, dict[str, Prediction]]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _build(root: Path, spec: dict[str, Any]) -> tuple[dict[str, Any], Suite]:
|
|
352
|
+
"""Read every file once, validate closure/relationships, and derive the lock."""
|
|
353
|
+
ref_rel = spec["references"]
|
|
354
|
+
ref_data = _locate(root, ref_rel, "references").read_bytes()
|
|
355
|
+
references = _parse_references(ref_data, ref_rel)
|
|
356
|
+
|
|
357
|
+
documents = {doc["id"]: doc for doc in spec["documents"]}
|
|
358
|
+
used = {ref.document_id for ref in references.values()}
|
|
359
|
+
unknown = sorted(used - documents.keys())
|
|
360
|
+
if unknown:
|
|
361
|
+
raise ValueError(f"references name unknown documents {unknown!r}")
|
|
362
|
+
unused = sorted(documents.keys() - used)
|
|
363
|
+
if unused:
|
|
364
|
+
raise ValueError(f"documents without reference crops {unused!r}")
|
|
365
|
+
split_of = {doc_id: doc["split"] for doc_id, doc in documents.items()}
|
|
366
|
+
|
|
367
|
+
leaks: dict[tuple[str, str], dict[str, set[str]]] = {}
|
|
368
|
+
|
|
369
|
+
def note(kind: str, digest: str, split: str, owner: str) -> None:
|
|
370
|
+
by_split = leaks.setdefault((kind, digest), {})
|
|
371
|
+
by_split.setdefault(split, set()).add(owner)
|
|
372
|
+
|
|
373
|
+
doc_entries = []
|
|
374
|
+
for doc_id, doc in documents.items():
|
|
375
|
+
digest = _sha256_file(_locate(root, doc["source"], f"document {doc_id!r} source"))
|
|
376
|
+
note("source", digest, doc["split"], doc_id)
|
|
377
|
+
doc_entries.append({**doc, "source": {"path": doc["source"], "sha256": digest}})
|
|
378
|
+
|
|
379
|
+
crop_entries = []
|
|
380
|
+
crop_paths: set[str] = set()
|
|
381
|
+
for ref in sorted(references.values(), key=lambda r: r.id):
|
|
382
|
+
if ref.crop_path in crop_paths:
|
|
383
|
+
raise ValueError(f"crop path {ref.crop_path!r} is used by more than one reference")
|
|
384
|
+
crop_paths.add(ref.crop_path)
|
|
385
|
+
digest = _sha256_file(_locate(root, ref.crop_path, f"reference {ref.id!r} crop"))
|
|
386
|
+
note("crop", digest, split_of[ref.document_id], ref.id)
|
|
387
|
+
crop_entries.append(
|
|
388
|
+
{
|
|
389
|
+
"id": ref.id,
|
|
390
|
+
"document_id": ref.document_id,
|
|
391
|
+
"path": ref.crop_path,
|
|
392
|
+
"sha256": digest,
|
|
393
|
+
"bounds": list(ref.bounds),
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
for (kind, digest), by_split in sorted(leaks.items()):
|
|
397
|
+
if len(by_split) > 1:
|
|
398
|
+
owners = {s: sorted(o) for s, o in sorted(by_split.items())}
|
|
399
|
+
raise ValueError(
|
|
400
|
+
f"split leakage: identical {kind} bytes (sha256 {digest}) appear in "
|
|
401
|
+
f"multiple splits {owners!r}"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
run_entries = []
|
|
405
|
+
predictions: dict[str, dict[str, Prediction]] = {}
|
|
406
|
+
for run in spec["runs"]:
|
|
407
|
+
pred_rel = run["predictions"]
|
|
408
|
+
data = _locate(root, pred_rel, f"run {run['name']!r} predictions").read_bytes()
|
|
409
|
+
parsed = _parse_predictions(data, f"run {run['name']!r} {pred_rel}")
|
|
410
|
+
missing = sorted(references.keys() - parsed.keys())
|
|
411
|
+
extra = sorted(parsed.keys() - references.keys())
|
|
412
|
+
if missing or extra:
|
|
413
|
+
raise ValueError(
|
|
414
|
+
f"run {run['name']!r}: sample ID mismatch: missing prediction IDs {missing!r}; "
|
|
415
|
+
f"extra prediction IDs {extra!r}"
|
|
416
|
+
)
|
|
417
|
+
predictions[run["name"]] = parsed
|
|
418
|
+
run_entries.append(
|
|
419
|
+
{
|
|
420
|
+
"name": run["name"],
|
|
421
|
+
"role": run["role"],
|
|
422
|
+
"metadata": run["metadata"],
|
|
423
|
+
"predictions": {
|
|
424
|
+
"path": pred_rel,
|
|
425
|
+
"sha256": _sha256_bytes(data),
|
|
426
|
+
"records": len(parsed),
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
dataset = {
|
|
432
|
+
"schema_version": SCHEMA_VERSION,
|
|
433
|
+
"release_id": spec["release_id"],
|
|
434
|
+
"data_kind": spec["data_kind"],
|
|
435
|
+
"reference": {
|
|
436
|
+
"path": ref_rel,
|
|
437
|
+
"sha256": _sha256_bytes(ref_data),
|
|
438
|
+
"records": len(references),
|
|
439
|
+
},
|
|
440
|
+
"documents": doc_entries,
|
|
441
|
+
"crops": crop_entries,
|
|
442
|
+
}
|
|
443
|
+
dataset_fp = _sha256_bytes(_canonical(dataset))
|
|
444
|
+
suite_fp = _sha256_bytes(_canonical({"dataset_fingerprint": dataset_fp, "runs": run_entries}))
|
|
445
|
+
lock = {
|
|
446
|
+
"kind": LOCK_KIND,
|
|
447
|
+
**dataset,
|
|
448
|
+
"runs": run_entries,
|
|
449
|
+
"dataset_fingerprint": dataset_fp,
|
|
450
|
+
"suite_fingerprint": suite_fp,
|
|
451
|
+
}
|
|
452
|
+
return lock, Suite(root, lock, references, split_of, predictions)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _lock_bytes(lock: dict[str, Any]) -> bytes:
|
|
456
|
+
text = json.dumps(lock, sort_keys=True, indent=2, ensure_ascii=False, allow_nan=False)
|
|
457
|
+
return (text + "\n").encode("utf-8")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _summary(lock: dict[str, Any]) -> dict[str, Any]:
|
|
461
|
+
return {
|
|
462
|
+
"release_id": lock["release_id"],
|
|
463
|
+
"data_kind": lock["data_kind"],
|
|
464
|
+
"dataset_fingerprint": lock["dataset_fingerprint"],
|
|
465
|
+
"suite_fingerprint": lock["suite_fingerprint"],
|
|
466
|
+
"documents": len(lock["documents"]),
|
|
467
|
+
"references": lock["reference"]["records"],
|
|
468
|
+
"runs": [run["name"] for run in lock["runs"]],
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def freeze(manifest_path: str | os.PathLike[str], lock_name: str = DEFAULT_LOCK) -> dict[str, Any]:
|
|
473
|
+
"""Validate a manifest and write its deterministic lock beside the inputs.
|
|
474
|
+
|
|
475
|
+
The manifest directory is the suite root. The lock must be a new file directly in
|
|
476
|
+
that directory (verify treats the lock's directory as the root); an existing lock
|
|
477
|
+
is never overwritten. Returns a summary dict.
|
|
478
|
+
"""
|
|
479
|
+
manifest = Path(manifest_path)
|
|
480
|
+
root = manifest.parent.resolve()
|
|
481
|
+
lock_rel = _rel_path(lock_name, "lock", "freeze")
|
|
482
|
+
if "/" in lock_rel:
|
|
483
|
+
raise ValueError(
|
|
484
|
+
f"freeze: lock {lock_rel!r} must be a file name directly beside the manifest"
|
|
485
|
+
)
|
|
486
|
+
lock_path = root / lock_rel
|
|
487
|
+
if lock_path.exists() or lock_path.is_symlink():
|
|
488
|
+
raise ValueError(f"freeze: refusing to overwrite existing lock {lock_rel!r}")
|
|
489
|
+
spec = _parse_spec(
|
|
490
|
+
_loads(manifest.read_text(encoding="utf-8"), manifest.name), f"manifest {manifest.name}"
|
|
491
|
+
)
|
|
492
|
+
lock, _ = _build(root, spec)
|
|
493
|
+
data = _lock_bytes(lock)
|
|
494
|
+
try:
|
|
495
|
+
with lock_path.open("xb") as stream:
|
|
496
|
+
stream.write(data)
|
|
497
|
+
except FileExistsError as exc:
|
|
498
|
+
raise ValueError(f"freeze: refusing to overwrite existing lock {lock_rel!r}") from exc
|
|
499
|
+
except BaseException:
|
|
500
|
+
lock_path.unlink(missing_ok=True)
|
|
501
|
+
raise
|
|
502
|
+
return {"lock": lock_rel, **_summary(lock)}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _first_difference(locked: object, actual: object, path: str = "lock") -> str | None:
|
|
506
|
+
if isinstance(locked, dict) and isinstance(actual, dict):
|
|
507
|
+
locked_map: dict[str, Any] = {str(k): v for k, v in locked.items()}
|
|
508
|
+
actual_map: dict[str, Any] = {str(k): v for k, v in actual.items()}
|
|
509
|
+
all_keys = sorted(set(locked_map) | set(actual_map))
|
|
510
|
+
for key in all_keys:
|
|
511
|
+
if key not in locked_map or key not in actual_map:
|
|
512
|
+
return f"{path}.{key} is {'absent' if key not in locked_map else 'unexpected'}"
|
|
513
|
+
found = _first_difference(locked_map[key], actual_map[key], f"{path}.{key}")
|
|
514
|
+
if found:
|
|
515
|
+
return found
|
|
516
|
+
return None
|
|
517
|
+
if isinstance(locked, list) and isinstance(actual, list):
|
|
518
|
+
if len(locked) != len(actual):
|
|
519
|
+
return f"{path} has {len(locked)} entries, expected {len(actual)}"
|
|
520
|
+
for index, (a, b) in enumerate(zip(locked, actual, strict=True)):
|
|
521
|
+
found = _first_difference(a, b, f"{path}[{index}]")
|
|
522
|
+
if found:
|
|
523
|
+
return found
|
|
524
|
+
return None
|
|
525
|
+
if type(locked) is not type(actual) or locked != actual:
|
|
526
|
+
return f"{path} is {str(locked)[:80]!r} but actual is {str(actual)[:80]!r}"
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _spec_from_lock(lock: object) -> dict[str, Any]:
|
|
531
|
+
try:
|
|
532
|
+
assert isinstance(lock, dict)
|
|
533
|
+
lock_dict: dict[str, Any] = {k: v for k, v in lock.items() if isinstance(k, str)}
|
|
534
|
+
if lock_dict.get("kind") != LOCK_KIND:
|
|
535
|
+
raise ValueError("not a khocr-gen benchmark lock")
|
|
536
|
+
ref = lock_dict["reference"]
|
|
537
|
+
if not isinstance(ref, dict):
|
|
538
|
+
raise TypeError
|
|
539
|
+
raw_docs = lock_dict["documents"]
|
|
540
|
+
if not isinstance(raw_docs, list):
|
|
541
|
+
raise TypeError
|
|
542
|
+
raw_runs = lock_dict["runs"]
|
|
543
|
+
if not isinstance(raw_runs, list):
|
|
544
|
+
raise TypeError
|
|
545
|
+
docs: list[dict[str, Any]] = []
|
|
546
|
+
for doc in raw_docs:
|
|
547
|
+
if not isinstance(doc, dict):
|
|
548
|
+
raise TypeError
|
|
549
|
+
doc_src = doc["source"]
|
|
550
|
+
if not isinstance(doc_src, dict):
|
|
551
|
+
raise TypeError
|
|
552
|
+
doc_entry: dict[str, Any] = {k: v for k, v in doc.items() if k != "source"}
|
|
553
|
+
doc_entry["source"] = doc_src["path"]
|
|
554
|
+
docs.append(doc_entry)
|
|
555
|
+
runs: list[dict[str, Any]] = []
|
|
556
|
+
for run in raw_runs:
|
|
557
|
+
if not isinstance(run, dict):
|
|
558
|
+
raise TypeError
|
|
559
|
+
run_preds = run["predictions"]
|
|
560
|
+
if not isinstance(run_preds, dict):
|
|
561
|
+
raise TypeError
|
|
562
|
+
runs.append(
|
|
563
|
+
{
|
|
564
|
+
"name": run["name"],
|
|
565
|
+
"role": run["role"],
|
|
566
|
+
"metadata": run["metadata"],
|
|
567
|
+
"predictions": run_preds["path"],
|
|
568
|
+
}
|
|
569
|
+
)
|
|
570
|
+
spec = {
|
|
571
|
+
"schema_version": lock_dict["schema_version"],
|
|
572
|
+
"release_id": lock_dict["release_id"],
|
|
573
|
+
"data_kind": lock_dict["data_kind"],
|
|
574
|
+
"references": ref["path"],
|
|
575
|
+
"documents": docs,
|
|
576
|
+
"runs": runs,
|
|
577
|
+
}
|
|
578
|
+
except (KeyError, TypeError, AttributeError, AssertionError) as exc:
|
|
579
|
+
raise ValueError("lock: malformed structure") from exc
|
|
580
|
+
return _parse_spec(spec, "lock")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def load_suite(
|
|
584
|
+
lock_path: str | os.PathLike[str],
|
|
585
|
+
*,
|
|
586
|
+
expect_dataset_fingerprint: str | None = None,
|
|
587
|
+
expect_suite_fingerprint: str | None = None,
|
|
588
|
+
) -> Suite:
|
|
589
|
+
"""Fully verify a lock against the files beside it; raise ValueError/OSError."""
|
|
590
|
+
path = Path(lock_path)
|
|
591
|
+
root = path.parent.resolve()
|
|
592
|
+
locked = _loads(path.read_text(encoding="utf-8"), path.name)
|
|
593
|
+
lock, suite = _build(root, _spec_from_lock(locked))
|
|
594
|
+
difference = _first_difference(locked, lock)
|
|
595
|
+
if difference:
|
|
596
|
+
raise ValueError(f"frozen suite does not match its files: {difference}")
|
|
597
|
+
for label, expected, actual in (
|
|
598
|
+
("dataset", expect_dataset_fingerprint, lock["dataset_fingerprint"]),
|
|
599
|
+
("suite", expect_suite_fingerprint, lock["suite_fingerprint"]),
|
|
600
|
+
):
|
|
601
|
+
if expected is not None and expected != actual:
|
|
602
|
+
raise ValueError(f"{label} fingerprint {actual} does not match expected {expected}")
|
|
603
|
+
return suite
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def verify(
|
|
607
|
+
lock_path: str | os.PathLike[str],
|
|
608
|
+
*,
|
|
609
|
+
check_near_duplicates: bool = False,
|
|
610
|
+
near_duplicate_threshold: float = 0.8,
|
|
611
|
+
**expected: str | None,
|
|
612
|
+
) -> dict[str, Any]:
|
|
613
|
+
"""Verify closure, hashes, relationships and leakage; return a summary."""
|
|
614
|
+
suite = load_suite(lock_path, **expected)
|
|
615
|
+
if check_near_duplicates:
|
|
616
|
+
records_by_split: dict[str, list[tuple[str, str]]] = {}
|
|
617
|
+
for ref_id, ref in suite.references.items():
|
|
618
|
+
split = suite.document_split.get(ref.document_id, "unknown")
|
|
619
|
+
records_by_split.setdefault(split, []).append((ref_id, ref.text))
|
|
620
|
+
leaks = check_near_duplicate_leakage(
|
|
621
|
+
records_by_split, threshold=near_duplicate_threshold
|
|
622
|
+
)
|
|
623
|
+
if leaks:
|
|
624
|
+
raise ValueError(
|
|
625
|
+
f"near-duplicate cross-split leakage detected ({len(leaks)} incident(s)): {leaks[:3]}"
|
|
626
|
+
)
|
|
627
|
+
summary = _summary(suite.lock)
|
|
628
|
+
summary["near_duplicates_checked"] = bool(check_near_duplicates)
|
|
629
|
+
return {"status": "ok", **summary}
|
|
630
|
+
|
|
631
|
+
def check_near_duplicate_leakage(
|
|
632
|
+
records_by_split: Mapping[str, Sequence[str | tuple[str, str]]],
|
|
633
|
+
*,
|
|
634
|
+
threshold: float = 0.8,
|
|
635
|
+
ngram_size: int = 3,
|
|
636
|
+
) -> list[dict[str, Any]]:
|
|
637
|
+
"""Detect cross-split leakage of nearly identical lines using character n-gram Jaccard similarity.
|
|
638
|
+
|
|
639
|
+
records_by_split maps split name to a sequence of texts or (id, text) tuples.
|
|
640
|
+
Returns a list of leak incidents:
|
|
641
|
+
[{"splits": (split_a, split_b), "similarity": float, "text_a": str, "text_b": str, ...}]
|
|
642
|
+
"""
|
|
643
|
+
def _ngrams(s: str) -> set[str]:
|
|
644
|
+
cleaned = unicodedata.normalize("NFC", s).strip().lower()
|
|
645
|
+
if len(cleaned) < ngram_size:
|
|
646
|
+
return {cleaned} if cleaned else set()
|
|
647
|
+
return {cleaned[i : i + ngram_size] for i in range(len(cleaned) - ngram_size + 1)}
|
|
648
|
+
|
|
649
|
+
split_items: list[tuple[str, str, str, set[str]]] = []
|
|
650
|
+
for split_name, items in records_by_split.items():
|
|
651
|
+
for idx, item in enumerate(items):
|
|
652
|
+
if isinstance(item, tuple):
|
|
653
|
+
item_id, item_text = item[0], item[1]
|
|
654
|
+
else:
|
|
655
|
+
item_id, item_text = f"{split_name}:{idx}", str(item)
|
|
656
|
+
ngs = _ngrams(item_text)
|
|
657
|
+
if ngs:
|
|
658
|
+
split_items.append((split_name, item_id, item_text, ngs))
|
|
659
|
+
|
|
660
|
+
leaks: list[dict[str, Any]] = []
|
|
661
|
+
for i in range(len(split_items)):
|
|
662
|
+
split_a, id_a, text_a, ngs_a = split_items[i]
|
|
663
|
+
for j in range(i + 1, len(split_items)):
|
|
664
|
+
split_b, id_b, text_b, ngs_b = split_items[j]
|
|
665
|
+
if split_a == split_b:
|
|
666
|
+
continue
|
|
667
|
+
intersection = len(ngs_a & ngs_b)
|
|
668
|
+
union = len(ngs_a | ngs_b)
|
|
669
|
+
sim = (intersection / union) if union else 0.0
|
|
670
|
+
if sim >= threshold:
|
|
671
|
+
leaks.append({
|
|
672
|
+
"splits": (split_a, split_b),
|
|
673
|
+
"similarity": round(sim, 4),
|
|
674
|
+
"id_a": id_a,
|
|
675
|
+
"text_a": text_a,
|
|
676
|
+
"id_b": id_b,
|
|
677
|
+
"text_b": text_b,
|
|
678
|
+
})
|
|
679
|
+
return leaks
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def format_comparison_markdown(report: dict[str, Any], *, variant: str = "raw") -> str:
|
|
683
|
+
"""Format a benchmark compare report into a Markdown table with CER, line accuracy, and deltas."""
|
|
684
|
+
baseline = report.get("baseline")
|
|
685
|
+
runs = report.get("runs", [])
|
|
686
|
+
deltas = report.get("deltas", {})
|
|
687
|
+
data_kind = report.get("data_kind")
|
|
688
|
+
evidence_note = report.get("evidence_note")
|
|
689
|
+
|
|
690
|
+
lines = [
|
|
691
|
+
f"# Benchmark Comparison ({report.get('release_id', 'unknown')})",
|
|
692
|
+
"",
|
|
693
|
+
f"- **Splits**: {', '.join(report.get('splits', []))}",
|
|
694
|
+
f"- **Baseline**: {baseline}",
|
|
695
|
+
f"- **Variant**: {variant}",
|
|
696
|
+
]
|
|
697
|
+
if data_kind:
|
|
698
|
+
lines.append(f"- **Data Kind**: {data_kind}")
|
|
699
|
+
if evidence_note:
|
|
700
|
+
lines.append(f"- **Evidence Note**: {evidence_note}")
|
|
701
|
+
lines.extend([
|
|
702
|
+
"",
|
|
703
|
+
"| Run | Role | CER | Line Accuracy | Δ CER | Δ Line Accuracy |",
|
|
704
|
+
"| :--- | :--- | :---: | :---: | :---: | :---: |",
|
|
705
|
+
])
|
|
706
|
+
|
|
707
|
+
for run in runs:
|
|
708
|
+
name = run["name"]
|
|
709
|
+
role = run["role"]
|
|
710
|
+
metrics = run.get(variant, {}).get("overall", {}).get("metrics", {})
|
|
711
|
+
cer = metrics.get("cer")
|
|
712
|
+
cer_str = f"{cer:.4f}" if cer is not None else "N/A"
|
|
713
|
+
acc = metrics.get("line_accuracy")
|
|
714
|
+
acc_str = f"{acc:.4f}" if acc is not None else "N/A"
|
|
715
|
+
|
|
716
|
+
if name == baseline:
|
|
717
|
+
d_cer_str = "-"
|
|
718
|
+
d_acc_str = "-"
|
|
719
|
+
else:
|
|
720
|
+
d_block = deltas.get(name, {}).get(variant, {}).get("overall", {})
|
|
721
|
+
d_cer = d_block.get("cer")
|
|
722
|
+
d_cer_str = f"{d_cer:+.4f}" if d_cer is not None else "N/A"
|
|
723
|
+
d_acc = d_block.get("line_accuracy")
|
|
724
|
+
d_acc_str = f"{d_acc:+.4f}" if d_acc is not None else "N/A"
|
|
725
|
+
|
|
726
|
+
lines.append(f"| {name} | {role} | {cer_str} | {acc_str} | {d_cer_str} | {d_acc_str} |")
|
|
727
|
+
|
|
728
|
+
return "\n".join(lines)
|
|
729
|
+
|
|
730
|
+
# ------------------------------------------------------------------------ comparison
|
|
731
|
+
|
|
732
|
+
_DELTA_METRICS = ("line_accuracy", "cer", "wer")
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _counts(ids: list[str], predictions: dict[str, Prediction]) -> dict[str, int]:
|
|
736
|
+
chosen = [predictions[i] for i in ids]
|
|
737
|
+
return {
|
|
738
|
+
"attempted": len(chosen),
|
|
739
|
+
"succeeded": sum(p.status == "ok" for p in chosen),
|
|
740
|
+
"failed": sum(p.status == "failed" for p in chosen),
|
|
741
|
+
"empty_output": sum(p.status == "ok" and p.text == "" for p in chosen),
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _score_run(
|
|
746
|
+
suite: Suite,
|
|
747
|
+
ids: list[str],
|
|
748
|
+
predictions: dict[str, Prediction],
|
|
749
|
+
normalization: str,
|
|
750
|
+
word_tokenization: str | None,
|
|
751
|
+
) -> dict[str, Any]:
|
|
752
|
+
"""Return per-variant ``{scope: {"counts", "metrics"}}`` blocks for one run."""
|
|
753
|
+
texts = {i: predictions[i].text for i in ids}
|
|
754
|
+
variants: dict[str, dict[str, Any]] = {}
|
|
755
|
+
for stratum in STRATA:
|
|
756
|
+
labels = {i: getattr(suite.references[i], stratum) for i in ids}
|
|
757
|
+
report = score_records(
|
|
758
|
+
{i: (suite.references[i].text, labels[i]) for i in ids},
|
|
759
|
+
texts,
|
|
760
|
+
normalization=normalization,
|
|
761
|
+
word_tokenization=word_tokenization,
|
|
762
|
+
)
|
|
763
|
+
members = {}
|
|
764
|
+
for sample_id, label in labels.items():
|
|
765
|
+
members.setdefault(label, []).append(sample_id)
|
|
766
|
+
for variant in ("raw", "normalized"):
|
|
767
|
+
if variant not in report:
|
|
768
|
+
continue
|
|
769
|
+
blocks = variants.setdefault(variant, {})
|
|
770
|
+
blocks.setdefault(
|
|
771
|
+
"overall",
|
|
772
|
+
{"counts": _counts(ids, predictions), "metrics": report[variant]["overall"]},
|
|
773
|
+
)
|
|
774
|
+
blocks[f"by_{stratum}"] = {
|
|
775
|
+
label: {
|
|
776
|
+
"counts": _counts(members[label], predictions),
|
|
777
|
+
"metrics": metrics,
|
|
778
|
+
}
|
|
779
|
+
for label, metrics in report[variant]["by_language"].items()
|
|
780
|
+
}
|
|
781
|
+
return variants
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _delta(candidate: dict[str, Any], baseline: dict[str, Any]) -> dict[str, Any]:
|
|
785
|
+
result: dict[str, Any] = {}
|
|
786
|
+
for metric in _DELTA_METRICS:
|
|
787
|
+
if metric in candidate["metrics"]:
|
|
788
|
+
a, b = candidate["metrics"][metric], baseline["metrics"][metric]
|
|
789
|
+
result[metric] = None if a is None or b is None else a - b
|
|
790
|
+
for count in ("failed", "empty_output"):
|
|
791
|
+
result[count] = candidate["counts"][count] - baseline["counts"][count]
|
|
792
|
+
return result
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _deltas(candidate: dict[str, Any], baseline: dict[str, Any]) -> dict[str, Any]:
|
|
796
|
+
result: dict[str, Any] = {}
|
|
797
|
+
for scope, block in candidate.items():
|
|
798
|
+
if scope == "overall":
|
|
799
|
+
result[scope] = _delta(block, baseline[scope])
|
|
800
|
+
else: # by_<stratum>: labels are reference data and may be any string
|
|
801
|
+
result[scope] = {
|
|
802
|
+
label: _delta(entry, baseline[scope][label]) for label, entry in block.items()
|
|
803
|
+
}
|
|
804
|
+
return result
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def compare(
|
|
808
|
+
lock_path: str | os.PathLike[str],
|
|
809
|
+
*,
|
|
810
|
+
splits: list[str],
|
|
811
|
+
baseline: str | None = None,
|
|
812
|
+
normalization: str = "none",
|
|
813
|
+
word_tokenization: str | None = None,
|
|
814
|
+
expect_dataset_fingerprint: str | None = None,
|
|
815
|
+
expect_suite_fingerprint: str | None = None,
|
|
816
|
+
) -> dict[str, Any]:
|
|
817
|
+
"""Verify the suite, then score every named run on the selected frozen references.
|
|
818
|
+
|
|
819
|
+
Returns one JSON-serializable report; any failure raises ValueError/OSError so
|
|
820
|
+
callers never emit a partial report. Deltas are candidate minus baseline, with
|
|
821
|
+
undefined (zero-denominator) metrics reported as ``None``.
|
|
822
|
+
"""
|
|
823
|
+
suite = load_suite(
|
|
824
|
+
lock_path,
|
|
825
|
+
expect_dataset_fingerprint=expect_dataset_fingerprint,
|
|
826
|
+
expect_suite_fingerprint=expect_suite_fingerprint,
|
|
827
|
+
)
|
|
828
|
+
lock = suite.lock
|
|
829
|
+
if not splits or any(s not in SPLITS for s in splits):
|
|
830
|
+
raise ValueError(f"splits must be a nonempty subset of {', '.join(SPLITS)}")
|
|
831
|
+
chosen_splits = sorted(set(splits), key=SPLITS.index)
|
|
832
|
+
names = [run["name"] for run in lock["runs"]]
|
|
833
|
+
if baseline is None:
|
|
834
|
+
baselines = [run["name"] for run in lock["runs"] if run["role"] == "baseline"]
|
|
835
|
+
if len(baselines) != 1:
|
|
836
|
+
raise ValueError(
|
|
837
|
+
f"--baseline is required unless exactly one run has role baseline "
|
|
838
|
+
f"(found {baselines!r})"
|
|
839
|
+
)
|
|
840
|
+
baseline = baselines[0]
|
|
841
|
+
elif baseline not in names:
|
|
842
|
+
raise ValueError(f"unknown baseline run {baseline!r}; runs: {names!r}")
|
|
843
|
+
|
|
844
|
+
ids = sorted(
|
|
845
|
+
sample_id
|
|
846
|
+
for sample_id, ref in suite.references.items()
|
|
847
|
+
if suite.document_split[ref.document_id] in chosen_splits
|
|
848
|
+
)
|
|
849
|
+
if not ids:
|
|
850
|
+
raise ValueError(f"no frozen references in splits {chosen_splits!r}")
|
|
851
|
+
|
|
852
|
+
scored: dict[str, dict[str, Any]] = {}
|
|
853
|
+
runs = []
|
|
854
|
+
for run in lock["runs"]:
|
|
855
|
+
scored[run["name"]] = _score_run(
|
|
856
|
+
suite, ids, suite.predictions[run["name"]], normalization, word_tokenization
|
|
857
|
+
)
|
|
858
|
+
runs.append({**run, **scored[run["name"]]})
|
|
859
|
+
deltas = {
|
|
860
|
+
name: {
|
|
861
|
+
"baseline": baseline,
|
|
862
|
+
**{variant: _deltas(blocks[variant], scored[baseline][variant]) for variant in blocks},
|
|
863
|
+
}
|
|
864
|
+
for name, blocks in scored.items()
|
|
865
|
+
if name != baseline
|
|
866
|
+
}
|
|
867
|
+
return {
|
|
868
|
+
"schema_version": SCHEMA_VERSION,
|
|
869
|
+
"scorer_schema_version": 1,
|
|
870
|
+
"release_id": lock["release_id"],
|
|
871
|
+
"data_kind": lock["data_kind"],
|
|
872
|
+
"evidence_note": _EVIDENCE_NOTE[lock["data_kind"]],
|
|
873
|
+
"dataset_fingerprint": lock["dataset_fingerprint"],
|
|
874
|
+
"suite_fingerprint": lock["suite_fingerprint"],
|
|
875
|
+
"reference": lock["reference"],
|
|
876
|
+
"splits": chosen_splits,
|
|
877
|
+
"selection": {"references": len(ids), "sha256": _sha256_bytes(_canonical(ids))},
|
|
878
|
+
"options": {"normalization": normalization, "word_tokenization": word_tokenization},
|
|
879
|
+
"baseline": baseline,
|
|
880
|
+
"runtime": {
|
|
881
|
+
"python": platform.python_version(),
|
|
882
|
+
"implementation": platform.python_implementation(),
|
|
883
|
+
"unicode_version": unicodedata.unidata_version,
|
|
884
|
+
"khocr_gen": __version__,
|
|
885
|
+
},
|
|
886
|
+
"runs": runs,
|
|
887
|
+
"deltas": deltas,
|
|
888
|
+
}
|