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/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
+ }