Spaces:
Sleeping
Sleeping
File size: 18,700 Bytes
50fbd5a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 | """How much does the PyMuPDF version change dm-bench? (issue #10)
Measures, on the public splits only, what a different PyMuPDF version does to
rendered pixels, fragment geometry, fragment pixels and baseline scores. It
never changes the pinned benchmark: the canonical release stays the one built
with the versions in requirements-bench.txt.
Two parts:
renderer only PDFs are made once in the canonical environment, then
rendered and built under each PyMuPDF version
full rebuild `python -m src.bench build` under each version (PDF writing
and rendering both change)
Steps, each run with the Python of the environment being measured:
python scripts/pymupdf_sensitivity.py make-pdfs PDFS (canonical env)
python scripts/pymupdf_sensitivity.py build-from-pdfs PDFS REL
python scripts/pymupdf_sensitivity.py render-pages PDFS RENDERS
python scripts/pymupdf_sensitivity.py compare --releases A B [--renders A B]
[--results A B] [--pdfs A B] [--solutions A B] [--overlap-only]
[--label NAME] --out SUMMARY
Release A and B are then solved and scored with the normal CLI
(`python -m src.bench solve` / `eval`). No secret is needed or read.
"""
from __future__ import annotations
import argparse
import contextlib
import json
import os
import re
import shutil
import sys
import numpy as np
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
from src.bench import build, canon, docgen, evaluate # noqa: E402
SPLITS = ("val", "test-dev")
INDEX = "index.json"
SPEC = "benchmark.json"
SCORE_KEYS = ("hit1", "hit5", "mrr", "perfect", "direct_acc", "neighbor_acc", "adj_f1")
# ---------------------------------------------------------------------------
# Source PDFs
# ---------------------------------------------------------------------------
def documents():
"""(tier, split, doc, doc_seed) for every public document, as build_doc keys them."""
for tier in build.TIER_SPECS:
for split in SPLITS:
for doc in build.doc_range(split, build.DEFAULT_DOCS):
seed = canon.stream_u64(build.PUBLIC_ENTROPY, canon.TIERS[tier], doc, 0,
canon.PURPOSE["layout"])
yield tier, split, doc, seed
def make_pdfs(out: str) -> dict:
"""Write every public source PDF plus an index keyed by doc_seed."""
index = {}
for tier, split, doc, seed in documents():
rel = f"{tier}/{split}/{doc}.pdf"
if str(seed) in index:
raise RuntimeError(f"doc_seed {seed} is not unique ({index[str(seed)]}, {rel})")
os.makedirs(os.path.join(out, tier, split), exist_ok=True)
docgen.make_document(seed, os.path.join(out, rel))
index[str(seed)] = rel
meta = {"documents": index, "build_env": build.build_env()}
with open(os.path.join(out, INDEX), "w") as fh:
fh.write(canon.dumps(meta))
return meta
def load_index(pdfs: str) -> dict:
with open(os.path.join(pdfs, INDEX)) as fh:
return json.load(fh)["documents"]
@contextlib.contextmanager
def pdfs_as_source(pdfs: str):
"""Make build_doc copy the saved PDF for a seed instead of writing a new one."""
index = load_index(pdfs)
def copy_pdf(seed: int, path: str) -> None:
rel = index.get(str(seed))
if rel is None:
raise KeyError(f"no saved PDF for doc_seed {seed}")
shutil.copyfile(os.path.join(pdfs, rel), path)
original = build.make_document
build.make_document = copy_pdf
try:
yield index
finally:
build.make_document = original
def build_from_pdfs(pdfs: str, out: str) -> dict:
"""Normal release writer, sequential so the in-process patch applies."""
with pdfs_as_source(pdfs):
return build.write_release(out, list(build.TIER_SPECS), dict(build.DEFAULT_DOCS),
list(SPLITS), workers=1)
def render_pages(pdfs: str, out: str) -> int:
"""Lossless page renders (.npy) for page-level pixel comparison."""
n = 0
for rel in load_index(pdfs).values():
stem = os.path.join(out, rel[:-len(".pdf")])
os.makedirs(os.path.dirname(stem), exist_ok=True)
for i, page in enumerate(docgen.render_document(os.path.join(pdfs, rel))):
np.save(f"{stem}_p{i}.npy", page)
n += 1
meta = {"build_env": build.build_env(), "pages": n}
with open(os.path.join(out, INDEX), "w") as fh:
fh.write(canon.dumps(meta))
return n
# ---------------------------------------------------------------------------
# Comparison
# ---------------------------------------------------------------------------
def _npy_files(root: str) -> list[str]:
found = []
for dirpath, _, files in os.walk(root):
found += [os.path.relpath(os.path.join(dirpath, f), root) for f in files if f.endswith(".npy")]
return sorted(found)
def compare_renders(a: str, b: str) -> dict:
files = _npy_files(a)
if files != _npy_files(b):
raise ValueError("render folders hold different pages")
identical = lighter_pages = 0
changed_frac, mean_abs, max_abs = [], [], 0
n_changed = n_lighter = 0
sum_signed = sum_abs = 0.0
for rel in files:
pa, pb = np.load(os.path.join(a, rel)), np.load(os.path.join(b, rel))
if pa.shape != pb.shape:
raise ValueError(f"{rel}: shape {pa.shape} vs {pb.shape}")
signed = pb.astype(np.int16) - pa.astype(np.int16) # > 0: lighter in B
diff = np.abs(signed)
identical += int(not diff.any())
changed = diff.any(axis=2)
changed_frac.append(float(changed.mean()))
mean_abs.append(float(diff.mean()))
max_abs = max(max_abs, int(diff.max()))
per_pixel = signed.mean(axis=2)[changed] # channel mean, changed pixels
n_changed += per_pixel.size
n_lighter += int((per_pixel > 0).sum())
sum_signed += float(per_pixel.sum())
sum_abs += float(np.abs(per_pixel).sum())
lighter_pages += int(signed.mean() > 0)
return {
"build_env": {"a": _read_json(os.path.join(a, INDEX))["build_env"],
"b": _read_json(os.path.join(b, INDEX))["build_env"]},
"pages": len(files),
"pages_identical": identical,
"changed_pixel_fraction": {"mean": float(np.mean(changed_frac)), "max": float(np.max(changed_frac))},
"mean_abs_diff": {"mean": float(np.mean(mean_abs)), "max": float(np.max(mean_abs))},
"max_abs_diff": max_abs,
"changed_pixels": n_changed,
"changed_pixels_lighter_in_b": n_lighter,
"changed_pixel_mean_signed_diff": sum_signed / n_changed if n_changed else 0.0,
"changed_pixel_mean_abs_diff": sum_abs / n_changed if n_changed else 0.0,
"pages_lighter_in_b": lighter_pages,
}
def _read_json(path: str):
with open(path) as fh:
return json.load(fh)
def _geometry(answer: dict) -> str:
"""Canonical answer with only the pixel-derived ink_frac removed."""
stripped = dict(answer)
stripped["fragments"] = {fid: {k: v for k, v in f.items() if k != "ink_frac"}
for fid, f in answer["fragments"].items()}
return canon.dumps(stripped)
def _digests(release: str, name: str) -> dict[str, str]:
"""rel path -> digest from SHA256SUMS (raw bytes) or CONTENT.sha256 (decoded)."""
out = {}
with open(os.path.join(release, name)) as fh:
for line in fh:
digest, rel = line.rstrip("\n").split(" ", 1)
out[rel] = digest
return out
def _blank_counts(a: str, b: str) -> dict:
"""blank_fragments per tier/split from both benchmark.json files."""
sa = _read_json(os.path.join(a, SPEC))["splits"]
sb = _read_json(os.path.join(b, SPEC))["splits"]
out = {f"{t}/{s}": {"a": sa[t][s]["blank_fragments"], "b": sb[t][s]["blank_fragments"]}
for t in sorted(sa) for s in sorted(sa[t])}
out["total"] = {"a": sum(v["a"] for v in out.values()), "b": sum(v["b"] for v in out.values())}
return out
def compare_releases(a: str, b: str, overlap_only: bool = False) -> dict:
"""Content (decoded) and raw-byte identity of two releases.
overlap_only compares just the files both hold, for a subset build
against a full release. Release-level files are then not compared.
"""
da, db = _digests(a, "CONTENT.sha256"), _digests(b, "CONTENT.sha256")
ba, bb = _digests(a, "SHA256SUMS"), _digests(b, "SHA256SUMS")
keys = sorted(k for k in da if k != SPEC)
if overlap_only:
missing = [k for k in keys if k not in db]
if missing or not keys: # A must be a subset of B
raise ValueError(f"{len(missing)} file(s) of {a} are not in {b}")
elif keys != sorted(k for k in db if k != SPEC):
raise ValueError("releases hold different files")
answers = [k for k in keys if k.startswith("answers/")]
pngs = [k for k in keys if k.endswith(".png")]
puzzles = [k for k in keys if k.endswith("puzzle.json")]
geometry_equal, ink_delta = 0, 0.0
ink_changed = ink_down = to_blank = to_ink = 0
for rel in answers:
aa, ab = _read_json(os.path.join(a, rel)), _read_json(os.path.join(b, rel))
geometry_equal += _geometry(aa) == _geometry(ab)
for fid, frag in aa["fragments"].items():
ia, ib = frag["ink_frac"], ab["fragments"][fid]["ink_frac"]
ink_delta = max(ink_delta, abs(ia - ib))
ink_changed += ia != ib
ink_down += ib < ia
to_blank += ia >= build.BLANK_INK > ib
to_ink += ib >= build.BLANK_INK > ia
return {
"pages": len(answers),
"geometry_identical": geometry_equal,
"answers_identical": sum(da[k] == db[k] for k in answers),
"ink_frac_max_abs_delta": round(ink_delta, 4),
"ink_frac_changed": ink_changed,
"ink_frac_lower_in_b": ink_down,
"fragments_became_blank": to_blank,
"fragments_became_inked": to_ink,
"puzzle_json_identical": sum(da[k] == db[k] for k in puzzles),
"puzzles": len(puzzles),
"fragments": len(pngs),
"fragment_pixels_identical": sum(da[k] == db[k] for k in pngs),
"files": len(keys),
"files_byte_identical": sum(ba[k] == bb[k] for k in keys),
"benchmark_json_byte_identical": None if overlap_only else ba[SPEC] == bb[SPEC],
"blank_fragments": None if overlap_only else _blank_counts(a, b),
"build_env": {"a": _read_json(os.path.join(a, SPEC))["build_env"],
"b": _read_json(os.path.join(b, SPEC))["build_env"]},
}
def compare_pdfs(a: str, b: str) -> dict:
"""Byte identity of two sets of source PDFs made by make-pdfs."""
ia, ib = load_index(a), load_index(b)
if ia != ib:
raise ValueError("PDF folders index different documents")
import fitz
same = same_objects = same_meta = 0
for rel in ia.values():
pa, pb = os.path.join(a, rel), os.path.join(b, rel)
with open(pa, "rb") as fa, open(pb, "rb") as fb:
same += fa.read() == fb.read()
same_objects += _pdf_objects(fitz, pa) == _pdf_objects(fitz, pb)
same_meta += _pdf_metadata(fitz, pa) == _pdf_metadata(fitz, pb)
return {"build_env": {"a": _read_json(os.path.join(a, INDEX))["build_env"],
"b": _read_json(os.path.join(b, INDEX))["build_env"]},
"documents": len(ia), "pdf_bytes_identical": same,
"pdf_objects_identical_except_info": same_objects,
"pdf_metadata_identical_except_producer": same_meta}
def _pdf_metadata(fitz, path: str) -> dict:
"""Document Info metadata (dates, title, format, ...) without the producer."""
doc = fitz.open(path)
try:
return {k: v for k, v in doc.metadata.items() if k != "producer"}
finally:
doc.close()
def _strip_info(text: str) -> str:
"""Drop an inline Info dictionary (no nested dicts) from one object's text."""
return re.sub(r"/Info<<[^<>]*>>", "", text)
def _pdf_objects(fitz, path: str) -> list:
"""Every PDF object (dictionary and raw stream) with the Info metadata removed.
MuPDF writes the Info dictionary either as a separate object or inline in
the catalog (`/Info<</Producer(MuPDF 1.28.2)>>`). Both forms are dropped
here, and _pdf_metadata compares the Info fields other than the producer.
"""
doc = fitz.open(path)
try:
objects = []
for xref in range(1, doc.xref_length()):
text = _strip_info(doc.xref_object(xref, compressed=True))
if "/Producer" in text:
continue
raw = doc.xref_stream_raw(xref) if doc.xref_is_stream(xref) else None
objects.append((text, raw))
return objects
finally:
doc.close()
def compare_pages(release_a: str, sols_a: str, release_b: str, sols_b: str) -> dict:
"""Per-page paired score changes (same pages, deterministic solver)."""
out = {}
for tier in build.TIER_SPECS:
for split in SPLITS:
for release, sols in ((release_a, sols_a), (release_b, sols_b)):
pages = os.listdir(os.path.join(release, "puzzles", tier, split))
bad = [p for p in pages
if evaluate.load_solution(os.path.join(sols, tier, split, f"{p}.json")) is None]
if bad:
raise ValueError(f"{sols}: {len(bad)} {tier}/{split} solution(s) missing or unreadable")
pa, _ = evaluate.evaluate_split(release_a, sols_a, tier, split)
pb, _ = evaluate.evaluate_split(release_b, sols_b, tier, split)
if [p.page_id for p in pa] != [p.page_id for p in pb]:
raise ValueError(f"{tier}/{split}: different pages")
for key in SCORE_KEYS:
row = out.setdefault(key, {"pages": 0, "changed": 0, "up": 0, "down": 0, "max_abs": 0.0})
for sa, sb in zip(pa, pb):
va, vb = getattr(sa, key), getattr(sb, key)
if (va is None) != (vb is None):
raise ValueError(f"{sa.page_id}: {key} scored on one side only")
if va is None:
continue
row["pages"] += 1
if abs(vb - va) > 1e-12:
row["changed"] += 1
row["up" if vb > va else "down"] += 1
row["max_abs"] = max(row["max_abs"], abs(vb - va))
return out
def _metric(entry):
return entry["mean"] if isinstance(entry, dict) else entry
def compare_results(a: str, b: str) -> dict:
ra, rb = _read_json(a), _read_json(b)
out = {}
for tier in sorted(ra["scores"]):
for split in sorted(ra["scores"][tier]):
sa, sb = ra["scores"][tier][split], rb["scores"][tier][split]
row = {}
for key in SCORE_KEYS:
if key not in sa or key not in sb: # Hit@k absent without candidates
continue
va, vb = _metric(sa[key]), _metric(sb[key])
row[key] = {"a": va, "b": vb, "delta": round(vb - va, 4)}
if isinstance(sa[key], dict):
row[key]["ci95_a"], row[key]["ci95_b"] = sa[key]["ci95"], sb[key]["ci95"]
half = (sa[key]["ci95"][1] - sa[key]["ci95"][0]) / 2
if half:
ratio = abs(vb - va) / half
elif vb == va:
ratio = 0.0
else:
ratio = None # changed against a zero-width interval
row[key]["delta_over_ci_half_width_a"] = ratio
row[key]["changed_vs_zero_width_ci"] = ratio is None
out[f"{tier}/{split}"] = row
return {"release_sha256": {"a": ra["release_sha256"], "b": rb["release_sha256"]},
"solvers": {"a": ra["solvers"], "b": rb["solvers"]},
"eval_version": {"a": ra["eval_version"], "b": rb["eval_version"]},
"scores": out}
def main(argv=None) -> int:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("make-pdfs")
s.add_argument("out")
s = sub.add_parser("build-from-pdfs")
s.add_argument("pdfs")
s.add_argument("out")
s = sub.add_parser("render-pages")
s.add_argument("pdfs")
s.add_argument("out")
s = sub.add_parser("compare")
s.add_argument("--releases", nargs=2, required=True)
s.add_argument("--renders", nargs=2)
s.add_argument("--results", nargs=2)
s.add_argument("--pdfs", nargs=2)
s.add_argument("--solutions", nargs=2, help="solution dirs for --releases, enables per-page pairs")
s.add_argument("--overlap-only", action="store_true")
s.add_argument("--out", required=True)
s.add_argument("--label", help="store under this key, keeping other labels already in --out")
args = p.parse_args(argv)
if args.cmd == "make-pdfs":
print(f"wrote {len(make_pdfs(args.out)['documents'])} PDFs to {args.out}")
elif args.cmd == "build-from-pdfs":
build_from_pdfs(args.pdfs, args.out)
print(f"wrote {args.out}")
elif args.cmd == "render-pages":
print(f"rendered {render_pages(args.pdfs, args.out)} pages to {args.out}")
else:
merged = _read_json(args.out) if args.label and os.path.exists(args.out) else {}
if "releases" in merged: # checked before any work
raise ValueError(f"{args.out} holds an unlabeled summary, refusing to merge")
summary = {"releases": compare_releases(*args.releases, overlap_only=args.overlap_only)}
if args.renders:
summary["renders"] = compare_renders(*args.renders)
if args.results:
summary["results"] = compare_results(*args.results)
if args.pdfs:
summary["pdfs"] = compare_pdfs(*args.pdfs)
if args.solutions:
summary["pages"] = compare_pages(args.releases[0], args.solutions[0],
args.releases[1], args.solutions[1])
if args.label:
merged[args.label] = summary
summary = merged
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
with open(args.out, "w") as fh:
fh.write(canon.dumps(summary))
print(json.dumps(summary, indent=1, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())
|