verifydoc 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. {verifydoc-0.1.0 → verifydoc-0.2.0}/CHANGELOG.md +21 -0
  2. {verifydoc-0.1.0 → verifydoc-0.2.0}/Makefile +3 -0
  3. {verifydoc-0.1.0 → verifydoc-0.2.0}/PKG-INFO +5 -1
  4. {verifydoc-0.1.0 → verifydoc-0.2.0}/README.md +4 -0
  5. {verifydoc-0.1.0 → verifydoc-0.2.0}/benchmark/card.md +3 -2
  6. verifydoc-0.2.0/benchmark/datasets/cord.py +150 -0
  7. verifydoc-0.2.0/benchmark/datasets/funsd.py +164 -0
  8. verifydoc-0.2.0/configs/cord-paddleocr.yaml +14 -0
  9. verifydoc-0.2.0/configs/cord.yaml +14 -0
  10. verifydoc-0.2.0/configs/funsd.yaml +12 -0
  11. verifydoc-0.2.0/docs/REAL_MODELS.md +61 -0
  12. verifydoc-0.2.0/docs/demo.gif +0 -0
  13. {verifydoc-0.1.0 → verifydoc-0.2.0}/paper/generated/calibration.md +10 -5
  14. {verifydoc-0.1.0 → verifydoc-0.2.0}/paper/generated/conformal.md +4 -2
  15. verifydoc-0.2.0/paper/generated/cord/calibration.md +34 -0
  16. verifydoc-0.2.0/paper/generated/cord/conformal.md +16 -0
  17. verifydoc-0.2.0/paper/generated/cord/extraction.md +5 -0
  18. verifydoc-0.2.0/paper/generated/cord/grounding.md +5 -0
  19. verifydoc-0.2.0/paper/generated/cord/rc_curves.png +0 -0
  20. verifydoc-0.2.0/paper/generated/cord/reliability.png +0 -0
  21. verifydoc-0.2.0/paper/generated/cord/selective.md +10 -0
  22. verifydoc-0.2.0/paper/generated/cord/summary.json +46 -0
  23. verifydoc-0.2.0/paper/generated/funsd/calibration.md +34 -0
  24. verifydoc-0.2.0/paper/generated/funsd/conformal.md +16 -0
  25. verifydoc-0.2.0/paper/generated/funsd/extraction.md +5 -0
  26. verifydoc-0.2.0/paper/generated/funsd/grounding.md +5 -0
  27. verifydoc-0.2.0/paper/generated/funsd/rc_curves.png +0 -0
  28. verifydoc-0.2.0/paper/generated/funsd/reliability.png +0 -0
  29. verifydoc-0.2.0/paper/generated/funsd/selective.md +10 -0
  30. verifydoc-0.2.0/paper/generated/funsd/summary.json +46 -0
  31. verifydoc-0.2.0/paper/generated/rc_curves.png +0 -0
  32. {verifydoc-0.1.0 → verifydoc-0.2.0}/paper/generated/selective.md +2 -1
  33. {verifydoc-0.1.0 → verifydoc-0.2.0}/paper/generated/summary.json +8 -6
  34. verifydoc-0.2.0/paper/main.tex +90 -0
  35. {verifydoc-0.1.0 → verifydoc-0.2.0}/pyproject.toml +1 -1
  36. verifydoc-0.2.0/scripts/make_demo_gif.py +167 -0
  37. verifydoc-0.2.0/tests/test_cord_loader.py +131 -0
  38. verifydoc-0.2.0/tests/test_funsd_loader.py +89 -0
  39. verifydoc-0.2.0/tests/test_harness_extractors.py +32 -0
  40. verifydoc-0.2.0/tests/test_learned_combiner.py +73 -0
  41. {verifydoc-0.1.0 → verifydoc-0.2.0}/tests/test_pipeline.py +26 -0
  42. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/__init__.py +1 -1
  43. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/adapters/text_search.py +7 -3
  44. verifydoc-0.2.0/verifydoc/confidence/learned.py +63 -0
  45. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/eval/harness.py +71 -24
  46. verifydoc-0.2.0/verifydoc/grounding/attach.py +173 -0
  47. verifydoc-0.1.0/benchmark/datasets/cord.py +0 -77
  48. verifydoc-0.1.0/paper/generated/rc_curves.png +0 -0
  49. verifydoc-0.1.0/verifydoc/grounding/attach.py +0 -87
  50. {verifydoc-0.1.0 → verifydoc-0.2.0}/.github/workflows/ci.yml +0 -0
  51. {verifydoc-0.1.0 → verifydoc-0.2.0}/.github/workflows/release.yml +0 -0
  52. {verifydoc-0.1.0 → verifydoc-0.2.0}/.gitignore +0 -0
  53. {verifydoc-0.1.0 → verifydoc-0.2.0}/.pre-commit-config.yaml +0 -0
  54. {verifydoc-0.1.0 → verifydoc-0.2.0}/CLAUDE.md +0 -0
  55. {verifydoc-0.1.0 → verifydoc-0.2.0}/LICENSE +0 -0
  56. {verifydoc-0.1.0 → verifydoc-0.2.0}/PROJECT.md +0 -0
  57. {verifydoc-0.1.0 → verifydoc-0.2.0}/benchmark/__init__.py +0 -0
  58. {verifydoc-0.1.0 → verifydoc-0.2.0}/benchmark/datasets/__init__.py +0 -0
  59. {verifydoc-0.1.0 → verifydoc-0.2.0}/benchmark/datasets/synthetic.py +0 -0
  60. {verifydoc-0.1.0 → verifydoc-0.2.0}/benchmark/labeling/guide.md +0 -0
  61. {verifydoc-0.1.0 → verifydoc-0.2.0}/configs/demo.yaml +0 -0
  62. {verifydoc-0.1.0 → verifydoc-0.2.0}/docs/USP.md +0 -0
  63. {verifydoc-0.1.0 → verifydoc-0.2.0}/paper/generated/extraction.md +0 -0
  64. {verifydoc-0.1.0 → verifydoc-0.2.0}/paper/generated/grounding.md +0 -0
  65. {verifydoc-0.1.0 → verifydoc-0.2.0}/paper/generated/reliability.png +0 -0
  66. {verifydoc-0.1.0 → verifydoc-0.2.0}/scripts/run_benchmark.py +0 -0
  67. {verifydoc-0.1.0 → verifydoc-0.2.0}/tests/__init__.py +0 -0
  68. {verifydoc-0.1.0 → verifydoc-0.2.0}/tests/test_calibration.py +0 -0
  69. {verifydoc-0.1.0 → verifydoc-0.2.0}/tests/test_calibrators.py +0 -0
  70. {verifydoc-0.1.0 → verifydoc-0.2.0}/tests/test_confidence.py +0 -0
  71. {verifydoc-0.1.0 → verifydoc-0.2.0}/tests/test_extraction.py +0 -0
  72. {verifydoc-0.1.0 → verifydoc-0.2.0}/tests/test_grounding.py +0 -0
  73. {verifydoc-0.1.0 → verifydoc-0.2.0}/tests/test_harness.py +0 -0
  74. {verifydoc-0.1.0 → verifydoc-0.2.0}/tests/test_selective.py +0 -0
  75. {verifydoc-0.1.0 → verifydoc-0.2.0}/tests/test_stats.py +0 -0
  76. {verifydoc-0.1.0 → verifydoc-0.2.0}/tests/test_types.py +0 -0
  77. {verifydoc-0.1.0 → verifydoc-0.2.0}/ui/streamlit_app.py +0 -0
  78. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/adapters/__init__.py +0 -0
  79. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/adapters/_ocr_common.py +0 -0
  80. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/adapters/api_vlm.py +0 -0
  81. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/adapters/base.py +0 -0
  82. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/adapters/docling.py +0 -0
  83. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/adapters/dots_ocr.py +0 -0
  84. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/adapters/mock.py +0 -0
  85. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/adapters/paddleocr_vl.py +0 -0
  86. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/calibration/__init__.py +0 -0
  87. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/calibration/base.py +0 -0
  88. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/calibration/conformal.py +0 -0
  89. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/calibration/histogram.py +0 -0
  90. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/calibration/isotonic.py +0 -0
  91. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/calibration/platt.py +0 -0
  92. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/calibration/splits.py +0 -0
  93. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/calibration/temperature.py +0 -0
  94. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/cli.py +0 -0
  95. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/confidence/__init__.py +0 -0
  96. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/confidence/combined.py +0 -0
  97. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/confidence/consensus.py +0 -0
  98. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/confidence/grounding_based.py +0 -0
  99. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/confidence/token_prob.py +0 -0
  100. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/confidence/verbalized.py +0 -0
  101. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/eval/__init__.py +0 -0
  102. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/eval/calibration.py +0 -0
  103. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/eval/extraction.py +0 -0
  104. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/eval/grounding.py +0 -0
  105. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/eval/selective.py +0 -0
  106. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/eval/stats.py +0 -0
  107. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/grounding/__init__.py +0 -0
  108. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/ingest/__init__.py +0 -0
  109. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/ingest/loader.py +0 -0
  110. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/pipeline.py +0 -0
  111. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/policy/__init__.py +0 -0
  112. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/policy/abstention.py +0 -0
  113. {verifydoc-0.1.0 → verifydoc-0.2.0}/verifydoc/types.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.0 — 2026-07-21
4
+
5
+ Real data, faster grounding, learned fusion.
6
+
7
+ - **CORD v2 slice**: real receipts with real text layers (word quads) and
8
+ located gold boxes; Indonesian thousands-comma prices score numerically.
9
+ - **FUNSD slice**: 199 scanned forms; question→answer links become gold
10
+ fields with exact annotated answer boxes.
11
+ - **Harness**: `dataset:` (synthetic | cord | funsd) and `extractor:` (any
12
+ adapter registry name) dispatch; `make results` regenerates all slices.
13
+ - **Learned combiner**: logistic fusion over signal values + missing
14
+ indicators, fit on the calibration split; reported as a `learned` row in
15
+ every table (ablation vs the transparent weighted mean).
16
+ - **Grounder performance (~1000×)**: exact-match fast path, token-anchored
17
+ fuzzy scan with length pruning, token-overlap scoring for paragraph-length
18
+ values. FUNSD run: hours → 8 seconds.
19
+ - **Fix**: text-search label matching uses word boundaries ("total" no longer
20
+ fires inside "Subtotal") — caught by the harness itself.
21
+ - Demo GIF rendered from a real pipeline run; GPU runbook
22
+ (docs/REAL_MODELS.md) + pinned real-model config; paper skeleton.
23
+
3
24
  ## v0.1.0 — 2026-07-21
4
25
 
5
26
  First public release.
@@ -23,8 +23,11 @@ cov:
23
23
  pytest --cov=verifydoc --cov-report=term-missing
24
24
 
25
25
  # Regenerate every table/figure in paper/ from pinned configs + seeds.
26
+ # (cord/funsd download their source data once into data/, then cache)
26
27
  results:
27
28
  $(PY) scripts/run_benchmark.py --config configs/demo.yaml --out paper/generated
29
+ $(PY) scripts/run_benchmark.py --config configs/cord.yaml --out paper/generated/cord
30
+ $(PY) scripts/run_benchmark.py --config configs/funsd.yaml --out paper/generated/funsd
28
31
 
29
32
  demo:
30
33
  streamlit run ui/streamlit_app.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: verifydoc
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A trust layer for document -> structured-JSON extraction: calibrated per-field confidence, source grounding, and abstention on top of any extractor.
5
5
  Project-URL: Homepage, https://github.com/bhaskargurram-ai/verifydoc
6
6
  Project-URL: Repository, https://github.com/bhaskargurram-ai/verifydoc
@@ -253,6 +253,10 @@ Description-Content-Type: text/markdown
253
253
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](pyproject.toml)
254
254
  [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
255
255
 
256
+ ![VerifyDoc demo: a silently-wrong total is caught by grounding and routed to review](docs/demo.gif)
257
+
258
+ *Above: a real pipeline run (`scripts/make_demo_gif.py`). The extractor returned `$1,432.50`; the page says `$1,234.50`. Grounding support drops to 0.78, the field misses the accept threshold, and the reviewer is pointed at the exact source region. The other three fields are auto-accepted.*
259
+
256
260
  ## The problem
257
261
 
258
262
  Modern document parsers read pages at 96%+ benchmark accuracy — and still emit **fluent, plausible, silently-wrong values** (`$42.50` → `$45.20`) with **no reliable per-field signal telling you which values to trust**. Commercial APIs (Box, Azure, Textract) sell field-level confidence as a closed feature. No popular open-source parser leads with it. ([full USP audit](docs/USP.md))
@@ -7,6 +7,10 @@
7
7
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](pyproject.toml)
8
8
  [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
9
9
 
10
+ ![VerifyDoc demo: a silently-wrong total is caught by grounding and routed to review](docs/demo.gif)
11
+
12
+ *Above: a real pipeline run (`scripts/make_demo_gif.py`). The extractor returned `$1,432.50`; the page says `$1,234.50`. Grounding support drops to 0.78, the field misses the accept threshold, and the reviewer is pointed at the exact source region. The other three fields are auto-accepted.*
13
+
10
14
  ## The problem
11
15
 
12
16
  Modern document parsers read pages at 96%+ benchmark accuracy — and still emit **fluent, plausible, silently-wrong values** (`$42.50` → `$45.20`) with **no reliable per-field signal telling you which values to trust**. Commercial APIs (Box, Azure, Textract) sell field-level confidence as a closed feature. No popular open-source parser leads with it. ([full USP audit](docs/USP.md))
@@ -10,8 +10,9 @@ extraction quality, calibration, selective risk, and grounding
10
10
  | Slice | Source | License | Status | Gold boxes |
11
11
  |---|---|---|---|---|
12
12
  | `synthetic` | generated in-repo (`benchmark/datasets/synthetic.py`) | Apache-2.0 | shipped, CI-run | yes (from layout) |
13
- | `cord` | naver-clova-ix/cord-v2 (HF hub) | CC-BY-4.0 (verify at release) | loader shipped | value-line boxes planned |
14
- | `funsd` / `sroie` / `docile` / `xfund` | official downloads | variessome research-only | planned | planned |
13
+ | `cord` | naver-clova-ix/cord-v2 (HF hub) | CC-BY-4.0 (verify at release) | shipped real text layers, harness-run | yes (located word quads) |
14
+ | `funsd` | guillaumejaume.github.io/FUNSD | research use | shippedreal forms, harness-run | yes (annotated answer boxes) |
15
+ | `sroie` / `docile` / `xfund` | official downloads | varies — some research-only | planned | planned |
15
16
 
16
17
  ## Redistribution policy (PROJECT.md §5.H)
17
18
 
@@ -0,0 +1,150 @@
1
+ """CORD v2 loader (receipts, CC-BY-4.0) -> VerifyDocBench items with REAL
2
+ text layers and gold boxes.
3
+
4
+ Pages are built from CORD's ``valid_line`` word quads (normalized to image
5
+ size), gold values from ``gt_parse`` (flattened to leaf paths with our path
6
+ convention, so arrays like ``menu[0].price`` score natively), and gold boxes
7
+ are located on the real page. Downloads stream from the HF hub
8
+ (pip install 'verifydoc[data]'); results are cached as JSON under ``data/``
9
+ so a run downloads once. Never imported by unit tests (golden rule #5) —
10
+ pure helpers below are tested with fixture rows instead.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from benchmark.datasets.synthetic import BenchDocument
20
+ from verifydoc.grounding.attach import _locate
21
+ from verifydoc.types import Document, FieldGold, Page, Schema, Word, flatten_json
22
+
23
+ CORD_SCHEMA_RAW: dict = {
24
+ "type": "object",
25
+ "properties": {
26
+ "menu": {
27
+ "type": "array",
28
+ "items": {
29
+ "type": "object",
30
+ "properties": {
31
+ "nm": {"type": "string", "x-scoring": "semantic"},
32
+ "cnt": {"type": "string"},
33
+ "price": {"type": "number", "x-numeric-tol": 0.01},
34
+ },
35
+ },
36
+ },
37
+ "sub_total": {
38
+ "type": "object",
39
+ "properties": {
40
+ "subtotal_price": {"type": "number", "x-numeric-tol": 0.01},
41
+ "tax_price": {"type": "number", "x-numeric-tol": 0.01},
42
+ "discount_price": {"type": "number", "x-numeric-tol": 0.01},
43
+ "service_price": {"type": "number", "x-numeric-tol": 0.01},
44
+ },
45
+ },
46
+ "total": {
47
+ "type": "object",
48
+ "properties": {
49
+ "total_price": {"type": "number", "x-numeric-tol": 0.01},
50
+ "cashprice": {"type": "number", "x-numeric-tol": 0.01},
51
+ "changeprice": {"type": "number", "x-numeric-tol": 0.01},
52
+ "creditcardprice": {"type": "number", "x-numeric-tol": 0.01},
53
+ "menuqty_cnt": {"type": "string"},
54
+ },
55
+ },
56
+ },
57
+ }
58
+
59
+
60
+ def document_from_valid_lines(
61
+ doc_id: str, valid_lines: list[dict[str, Any]], width: int, height: int
62
+ ) -> Document:
63
+ """Build a real text-layer Document from CORD ``valid_line`` word quads."""
64
+ words: list[Word] = []
65
+ lines: list[str] = []
66
+ for line in valid_lines:
67
+ line_words = line.get("words") or []
68
+ texts = []
69
+ for w in line_words:
70
+ quad = w["quad"]
71
+ xs = [quad["x1"], quad["x2"], quad["x3"], quad["x4"]]
72
+ ys = [quad["y1"], quad["y2"], quad["y3"], quad["y4"]]
73
+ bbox = (
74
+ max(0.0, min(xs) / width),
75
+ max(0.0, min(ys) / height),
76
+ min(1.0, max(xs) / width),
77
+ min(1.0, max(ys) / height),
78
+ )
79
+ if bbox[2] > bbox[0] and bbox[3] > bbox[1]:
80
+ words.append(Word(text=w["text"], bbox=bbox))
81
+ texts.append(w["text"])
82
+ if texts:
83
+ lines.append(" ".join(texts))
84
+ page = Page(
85
+ page=0, width=float(width), height=float(height), text="\n".join(lines), words=words
86
+ )
87
+ return Document(doc_id=doc_id, pages=[page])
88
+
89
+
90
+ def golds_from_gt_parse(gt_parse: dict[str, Any], schema: Schema, doc: Document) -> list[FieldGold]:
91
+ """Flatten gt_parse into scored gold fields; locate gold boxes on the page."""
92
+ golds = []
93
+ for path, value in flatten_json(gt_parse).items():
94
+ if value is None or str(value).strip() == "":
95
+ continue
96
+ leaf = schema.leaf(path)
97
+ golds.append(
98
+ FieldGold(
99
+ path=path,
100
+ value=value,
101
+ scoring=leaf.scoring if leaf else "exact",
102
+ numeric_tol=leaf.numeric_tol if leaf else 1e-6,
103
+ gold_box=_locate(str(value), doc, min_support=0.7),
104
+ )
105
+ )
106
+ return golds
107
+
108
+
109
+ def load(
110
+ split: str = "validation",
111
+ limit: int | None = 100,
112
+ cache_dir: str | Path = "data",
113
+ ) -> list[BenchDocument]: # pragma: no cover - network on first call
114
+ """Load CORD v2 (streamed; JSON-cached locally after the first download)."""
115
+ schema = Schema.from_json_schema(CORD_SCHEMA_RAW, name="cord-receipt")
116
+ cache = Path(cache_dir) / f"cord_{split}_{limit or 'all'}.json"
117
+ if cache.exists():
118
+ rows = json.loads(cache.read_text(encoding="utf-8"))
119
+ else:
120
+ try:
121
+ from datasets import load_dataset
122
+ except ImportError as exc:
123
+ raise ImportError("CORD loader requires: pip install 'verifydoc[data]'") from exc
124
+ stream = load_dataset("naver-clova-ix/cord-v2", split=split, streaming=True)
125
+ rows = []
126
+ for i, row in enumerate(stream):
127
+ if limit is not None and i >= limit:
128
+ break
129
+ gt = json.loads(row["ground_truth"])
130
+ width, height = row["image"].size
131
+ rows.append(
132
+ {
133
+ "gt_parse": gt["gt_parse"],
134
+ "valid_line": gt["valid_line"],
135
+ "width": width,
136
+ "height": height,
137
+ }
138
+ )
139
+ cache.parent.mkdir(parents=True, exist_ok=True)
140
+ cache.write_text(json.dumps(rows), encoding="utf-8")
141
+
142
+ out = []
143
+ for i, row in enumerate(rows):
144
+ doc = document_from_valid_lines(
145
+ f"cord-{split}-{i:05d}", row["valid_line"], row["width"], row["height"]
146
+ )
147
+ golds = golds_from_gt_parse(row["gt_parse"], schema, doc)
148
+ if golds and doc.pages[0].words:
149
+ out.append(BenchDocument(doc=doc, schema=schema, golds=golds))
150
+ return out
@@ -0,0 +1,164 @@
1
+ """FUNSD loader (199 scanned forms, noisy real-world KIE) -> VerifyDocBench.
2
+
3
+ Question->answer links become gold fields: the path is a slug of the question
4
+ text, the value is the linked answer text, and the gold box is the exact
5
+ union of the answer's word boxes (no locating needed — FUNSD annotates them).
6
+ Download (~16 MB zip) happens once into ``data/``; unit tests use fixture
7
+ annotations only (golden rule #5).
8
+
9
+ License note (benchmark/card.md): FUNSD permits research use; we ship this
10
+ loader + our added splits, never the images.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from benchmark.datasets.synthetic import BenchDocument
21
+ from verifydoc.eval.extraction import parse_number
22
+ from verifydoc.types import Document, FieldGold, Grounding, Page, Schema, SchemaLeaf, Word
23
+
24
+ FUNSD_URL = "https://guillaumejaume.github.io/FUNSD/dataset.zip"
25
+
26
+ _SLUG_RE = re.compile(r"[^a-z0-9]+")
27
+
28
+
29
+ def slugify(question: str) -> str:
30
+ """Question text -> a stable field path ('DATE:' -> 'date')."""
31
+ slug = _SLUG_RE.sub("_", question.casefold()).strip("_")
32
+ return slug or "field"
33
+
34
+
35
+ def _norm_box(box: list[int], width: int, height: int) -> tuple[float, float, float, float] | None:
36
+ x0, y0, x1, y1 = box
37
+ bbox = (
38
+ max(0.0, x0 / width),
39
+ max(0.0, y0 / height),
40
+ min(1.0, x1 / width),
41
+ min(1.0, y1 / height),
42
+ )
43
+ if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
44
+ return None
45
+ return bbox
46
+
47
+
48
+ def bench_from_annotation(
49
+ doc_id: str, annotation: dict[str, Any], width: int, height: int
50
+ ) -> BenchDocument | None:
51
+ """Build one benchmark item from a FUNSD annotation dict."""
52
+ entries = {e["id"]: e for e in annotation.get("form", [])}
53
+
54
+ words: list[Word] = []
55
+ lines: list[str] = []
56
+ for entry in annotation.get("form", []):
57
+ texts = []
58
+ for w in entry.get("words", []):
59
+ bbox = _norm_box(w["box"], width, height)
60
+ if bbox is not None and w["text"].strip():
61
+ words.append(Word(text=w["text"], bbox=bbox))
62
+ texts.append(w["text"])
63
+ if texts:
64
+ lines.append(" ".join(texts))
65
+ doc = Document(
66
+ doc_id=doc_id,
67
+ pages=[
68
+ Page(
69
+ page=0, width=float(width), height=float(height), text="\n".join(lines), words=words
70
+ )
71
+ ],
72
+ )
73
+
74
+ # question -> linked answers, in annotation order
75
+ answers_by_question: dict[int, list[int]] = {}
76
+ for entry in annotation.get("form", []):
77
+ for a, b in entry.get("linking", []):
78
+ qa = (a, b)
79
+ if (
80
+ entries.get(qa[0], {}).get("label") == "question"
81
+ and entries.get(qa[1], {}).get("label") == "answer"
82
+ ):
83
+ answers_by_question.setdefault(qa[0], [])
84
+ if qa[1] not in answers_by_question[qa[0]]:
85
+ answers_by_question[qa[0]].append(qa[1])
86
+
87
+ golds: list[FieldGold] = []
88
+ leaves: list[SchemaLeaf] = []
89
+ seen_paths: set[str] = set()
90
+ for q_id, a_ids in answers_by_question.items():
91
+ question = entries[q_id]["text"].strip()
92
+ # DECISION: multiple linked answers concatenate in annotation order.
93
+ value = " ".join(entries[a]["text"].strip() for a in a_ids if entries[a]["text"].strip())
94
+ if not question or not value:
95
+ continue
96
+ path = slugify(question)
97
+ n = 2
98
+ while path in seen_paths:
99
+ path = f"{slugify(question)}_{n}"
100
+ n += 1
101
+ seen_paths.add(path)
102
+
103
+ answer_boxes = [
104
+ _norm_box(w["box"], width, height) for a in a_ids for w in entries[a].get("words", [])
105
+ ]
106
+ answer_boxes = [b for b in answer_boxes if b is not None]
107
+ gold_box = (
108
+ Grounding(
109
+ page=0,
110
+ bbox=(
111
+ min(b[0] for b in answer_boxes),
112
+ min(b[1] for b in answer_boxes),
113
+ max(b[2] for b in answer_boxes),
114
+ max(b[3] for b in answer_boxes),
115
+ ),
116
+ support=1.0,
117
+ )
118
+ if answer_boxes
119
+ else None
120
+ )
121
+ scoring = "numeric" if parse_number(value) is not None else "semantic"
122
+ golds.append(
123
+ FieldGold(path=path, value=value, scoring=scoring, numeric_tol=1e-6, gold_box=gold_box)
124
+ )
125
+ leaves.append(SchemaLeaf(path=path, type="string", scoring=scoring))
126
+
127
+ if not golds or not words:
128
+ return None
129
+ schema = Schema(name=f"funsd-{doc_id}", leaves=leaves)
130
+ return BenchDocument(doc=doc, schema=schema, golds=golds)
131
+
132
+
133
+ def load(
134
+ split: str = "testing", limit: int | None = None, cache_dir: str | Path = "data"
135
+ ) -> list[BenchDocument]: # pragma: no cover - network on first call
136
+ """Load FUNSD (downloads + unzips once into ``cache_dir``)."""
137
+ from PIL import Image
138
+
139
+ root = Path(cache_dir) / "funsd"
140
+ if not root.exists():
141
+ import io
142
+ import urllib.request
143
+ import zipfile
144
+
145
+ Path(cache_dir).mkdir(parents=True, exist_ok=True)
146
+ with urllib.request.urlopen(FUNSD_URL) as resp:
147
+ payload = resp.read()
148
+ with zipfile.ZipFile(io.BytesIO(payload)) as zf:
149
+ zf.extractall(root)
150
+
151
+ ann_dir = root / "dataset" / f"{split}_data" / "annotations"
152
+ img_dir = root / "dataset" / f"{split}_data" / "images"
153
+ out: list[BenchDocument] = []
154
+ for i, ann_path in enumerate(sorted(ann_dir.glob("*.json"))):
155
+ if limit is not None and i >= limit:
156
+ break
157
+ annotation = json.loads(ann_path.read_text(encoding="utf-8"))
158
+ img_path = img_dir / f"{ann_path.stem}.png"
159
+ with Image.open(img_path) as img:
160
+ width, height = img.size
161
+ item = bench_from_annotation(f"funsd-{split}-{ann_path.stem}", annotation, width, height)
162
+ if item is not None:
163
+ out.append(item)
164
+ return out
@@ -0,0 +1,14 @@
1
+ # Pinned real-model experiment (issue #3): PaddleOCR-VL on CORD receipts.
2
+ # Needs a 24 GB GPU + `pip install paddlepaddle-gpu paddleocr` and CORD
3
+ # images exported to disk — see docs/REAL_MODELS.md.
4
+ dataset: cord
5
+ split: validation
6
+ limit: 100
7
+ extractor: paddleocr-vl # any name from the adapter registry
8
+ seed: 7
9
+ k: 5
10
+ calibration_fraction: 0.5
11
+ alphas: [0.02, 0.05]
12
+ n_boot: 300
13
+ # record at run time for the paper table header:
14
+ # paddleocr==<version>, GPU model, driver version
@@ -0,0 +1,14 @@
1
+ # CORD v2 slice: REAL receipts, REAL text layers + gold boxes; extractor
2
+ # errors simulated at seeded rates (real-model configs land with issue #3).
3
+ # First run downloads ~100 receipts from the HF hub, then caches to data/.
4
+ dataset: cord
5
+ split: validation
6
+ limit: 100
7
+ seed: 7
8
+ k: 5
9
+ error_rate: 0.18
10
+ omit_rate: 0.04
11
+ hallucinate_rate: 0.06
12
+ calibration_fraction: 0.5
13
+ alphas: [0.02, 0.05]
14
+ n_boot: 300
@@ -0,0 +1,12 @@
1
+ # FUNSD slice: real scanned forms, question->answer gold fields with exact
2
+ # annotated answer boxes; extractor errors simulated at seeded rates.
3
+ dataset: funsd
4
+ split: testing # 50 forms; use `training` (149) for scale
5
+ seed: 7
6
+ k: 5
7
+ error_rate: 0.18
8
+ omit_rate: 0.04
9
+ hallucinate_rate: 0.06
10
+ calibration_fraction: 0.5
11
+ alphas: [0.02, 0.05]
12
+ n_boot: 300
@@ -0,0 +1,61 @@
1
+ # Running real extractors (GPU runbook)
2
+
3
+ The shipped adapters run PaddleOCR-VL and dots.ocr on a single 24 GB GPU.
4
+ This is the paper's headline experiment (issue #3): the same harness, with a
5
+ real extractor instead of the simulated one.
6
+
7
+ ## One-time setup (Linux + NVIDIA, 24 GB)
8
+
9
+ ```bash
10
+ git clone https://github.com/bhaskargurram-ai/verifydoc && cd verifydoc
11
+ python3.11 -m venv .venv && source .venv/bin/activate
12
+ pip install -e ".[dev,pdf,data]"
13
+
14
+ # PaddleOCR-VL
15
+ pip install paddlepaddle-gpu paddleocr
16
+
17
+ # dots.ocr
18
+ pip install torch transformers accelerate
19
+ ```
20
+
21
+ ## Smoke test one receipt
22
+
23
+ ```bash
24
+ python - <<'EOF'
25
+ from benchmark.datasets import cord
26
+ from verifydoc.adapters import get_adapter
27
+
28
+ item = cord.load(split="validation", limit=1)[0]
29
+ adapter = get_adapter("paddleocr-vl")
30
+ for p in adapter.extract(item.doc, item.schema):
31
+ print(p.path, p.value, p.meta.get("token_logprobs"))
32
+ EOF
33
+ ```
34
+
35
+ Note: OCR adapters read `page.image_path` — for CORD, export images first
36
+ (`row["image"].save(...)`) and set `image_path` on each page; a
37
+ `--with-images` flag on the CORD loader is the intended patch (part of
38
+ issue #3).
39
+
40
+ ## Full run
41
+
42
+ ```bash
43
+ python scripts/run_benchmark.py --config configs/cord-paddleocr.yaml \
44
+ --out paper/generated/cord-paddleocr
45
+ ```
46
+
47
+ `configs/cord-paddleocr.yaml` is checked in as the pinned experiment config;
48
+ the harness dispatches `extractor:` through the adapter registry
49
+ (`mock` | `text-search` | `paddleocr-vl` | `dots-ocr` | `docling` | `api-vlm`).
50
+
51
+ ## What to report
52
+
53
+ The identical tables the simulated run produces (`calibration.md`,
54
+ `selective.md`, `conformal.md`, `grounding.md`) — swap them into the paper
55
+ via `make results`. Pin model versions in the config (`paddleocr==x.y.z`)
56
+ and record GPU + driver in the table header for reproducibility.
57
+
58
+ ## Cloud options
59
+
60
+ Any of: RunPod / Lambda / Vast single RTX 4090 (24 GB) instance ≈ $0.4–0.8/hr;
61
+ the full 100-receipt sweep with k=5 sampling is an hours-not-days job.
Binary file
@@ -12,11 +12,11 @@
12
12
  | verbalized | platt | 0.0430 | 0.0750 | 0.1047 | 0.3673 |
13
13
  | verbalized | isotonic | 0.0419 | 0.0720 | 0.1050 | 0.3673 |
14
14
  | verbalized | histogram | 0.0440 | 0.0973 | 0.1044 | 0.3657 |
15
- | grounding | raw | 0.0805 | 0.0762 | 0.0598 | 0.1535 |
16
- | grounding | temperature | 0.0563 | 0.0603 | 0.0402 | 0.1108 |
17
- | grounding | platt | 0.0001 | 0.0000 | 0.0000 | 0.0001 |
18
- | grounding | isotonic | 0.0000 | 0.0000 | 0.0000 | 0.0000 |
19
- | grounding | histogram | 0.0000 | 0.0000 | 0.0048 | 0.0157 |
15
+ | grounding | raw | 0.0856 | 0.0711 | 0.0655 | 0.3134 |
16
+ | grounding | temperature | 0.0675 | 0.0605 | 0.0439 | 0.1371 |
17
+ | grounding | platt | 0.0513 | 0.0146 | 0.0230 | 0.1106 |
18
+ | grounding | isotonic | 0.0017 | 0.0064 | 0.0056 | 0.0237 |
19
+ | grounding | histogram | 0.0023 | 0.0023 | 0.0090 | 0.0277 |
20
20
  | consensus | raw | 0.1705 | 0.1705 | 0.0605 | 0.2222 |
21
21
  | consensus | temperature | 0.0608 | 0.0608 | 0.0329 | 0.1070 |
22
22
  | consensus | platt | 0.0111 | 0.0111 | 0.0113 | 0.0607 |
@@ -27,3 +27,8 @@
27
27
  | combined | platt | 0.0109 | 0.0109 | 0.0105 | 0.0503 |
28
28
  | combined | isotonic | 0.0107 | 0.0107 | 0.0101 | 0.1693 |
29
29
  | combined | histogram | 0.0113 | 0.0113 | 0.0112 | 0.1825 |
30
+ | learned | raw | 0.0006 | 0.0199 | 0.0110 | 0.0553 |
31
+ | learned | temperature | 0.0026 | 0.0183 | 0.0111 | 0.0553 |
32
+ | learned | platt | 0.0111 | 0.0111 | 0.0109 | 0.0574 |
33
+ | learned | isotonic | 0.0114 | 0.0114 | 0.0113 | 0.1927 |
34
+ | learned | histogram | 0.0002 | 0.0195 | 0.0112 | 0.0622 |
@@ -6,9 +6,11 @@
6
6
  | token_prob | 0.0500 | 0.9230 | 0.8779 | 0.0199 | True |
7
7
  | verbalized | 0.0200 | inf | 0.0000 | 0.0000 | True |
8
8
  | verbalized | 0.0500 | inf | 0.0000 | 0.0000 | True |
9
- | grounding | 0.0200 | 0.8667 | 0.8837 | 0.0000 | True |
10
- | grounding | 0.0500 | 0.8462 | 0.9128 | 0.0318 | True |
9
+ | grounding | 0.0200 | 0.8667 | 0.8779 | 0.0000 | True |
10
+ | grounding | 0.0500 | 0.8462 | 0.9070 | 0.0321 | True |
11
11
  | consensus | 0.0200 | 0.2000 | 1.0000 | 0.0114 | True |
12
12
  | consensus | 0.0500 | 0.2000 | 1.0000 | 0.0114 | True |
13
13
  | combined | 0.0200 | 0.4761 | 1.0000 | 0.0114 | True |
14
14
  | combined | 0.0500 | 0.4761 | 1.0000 | 0.0114 | True |
15
+ | learned | 0.0200 | 0.9741 | 1.0000 | 0.0114 | True |
16
+ | learned | 0.0500 | 0.9741 | 1.0000 | 0.0114 | True |
@@ -0,0 +1,34 @@
1
+ # Calibration: signal x calibrator (test split)
2
+
3
+ | signal | calibrator | ece | adaptive_ece | brier | nll |
4
+ |---|---|---|---|---|---|
5
+ | token_prob | raw | 0.0957 | 0.1080 | 0.1206 | 0.4056 |
6
+ | token_prob | temperature | 0.1088 | 0.1175 | 0.1085 | 0.3563 |
7
+ | token_prob | platt | 0.0537 | 0.0560 | 0.0842 | 0.3137 |
8
+ | token_prob | isotonic | 0.0471 | 0.0536 | 0.0847 | 0.3316 |
9
+ | token_prob | histogram | 0.0535 | 0.0674 | 0.0887 | 0.3061 |
10
+ | verbalized | raw | 0.0610 | 0.0687 | 0.1246 | 0.4348 |
11
+ | verbalized | temperature | 0.0201 | 0.0556 | 0.1222 | 0.4106 |
12
+ | verbalized | platt | 0.0089 | 0.0414 | 0.1210 | 0.4067 |
13
+ | verbalized | isotonic | 0.0128 | 0.0490 | 0.1211 | 0.4470 |
14
+ | verbalized | histogram | 0.0101 | 0.0542 | 0.1214 | 0.4083 |
15
+ | grounding | raw | 0.0882 | 0.0816 | 0.0761 | 1.0679 |
16
+ | grounding | temperature | 0.0663 | 0.0761 | 0.0668 | 0.2475 |
17
+ | grounding | platt | 0.0429 | 0.0470 | 0.0545 | 0.2359 |
18
+ | grounding | isotonic | 0.0281 | 0.0468 | 0.0485 | 0.2854 |
19
+ | grounding | histogram | 0.0292 | 0.0482 | 0.0494 | 0.3670 |
20
+ | consensus | raw | 0.1541 | 0.1573 | 0.0875 | 0.5650 |
21
+ | consensus | temperature | 0.0892 | 0.0947 | 0.0751 | 0.5385 |
22
+ | consensus | platt | 0.0093 | 0.0461 | 0.0467 | 0.1955 |
23
+ | consensus | isotonic | 0.0271 | 0.0475 | 0.0478 | 0.2442 |
24
+ | consensus | histogram | 0.0271 | 0.0453 | 0.0478 | 0.2444 |
25
+ | combined | raw | 0.0845 | 0.0724 | 0.0516 | 0.2203 |
26
+ | combined | temperature | 0.0517 | 0.0360 | 0.0446 | 0.2210 |
27
+ | combined | platt | 0.0323 | 0.0367 | 0.0451 | 0.2023 |
28
+ | combined | isotonic | 0.0232 | 0.0376 | 0.0390 | 0.2944 |
29
+ | combined | histogram | 0.0212 | 0.0387 | 0.0373 | 0.2452 |
30
+ | learned | raw | 0.0131 | 0.0256 | 0.0424 | 0.1744 |
31
+ | learned | temperature | 0.0250 | 0.0311 | 0.0431 | 0.1796 |
32
+ | learned | platt | 0.0269 | 0.0309 | 0.0368 | 0.1869 |
33
+ | learned | isotonic | 0.0200 | 0.0427 | 0.0362 | 0.2394 |
34
+ | learned | histogram | 0.0211 | 0.0446 | 0.0371 | 0.2058 |
@@ -0,0 +1,16 @@
1
+ # Conformal abstention: guarantee vs forced review
2
+
3
+ | signal | alpha | threshold | test_coverage | achieved_risk | guarantee_held |
4
+ |---|---|---|---|---|---|
5
+ | token_prob | 0.0200 | 0.9308 | 0.8165 | 0.0486 | False |
6
+ | token_prob | 0.0500 | 0.9170 | 0.8678 | 0.0705 | False |
7
+ | verbalized | 0.0200 | inf | 0.0000 | 0.0000 | True |
8
+ | verbalized | 0.0500 | inf | 0.0000 | 0.0000 | True |
9
+ | grounding | 0.0200 | 0.9048 | 0.8612 | 0.0365 | False |
10
+ | grounding | 0.0500 | 0.7143 | 0.8992 | 0.0570 | False |
11
+ | consensus | 0.0200 | inf | 0.0000 | 0.0000 | True |
12
+ | consensus | 0.0500 | 0.2000 | 1.0000 | 0.0494 | True |
13
+ | combined | 0.0200 | 0.6793 | 0.9793 | 0.0358 | False |
14
+ | combined | 0.0500 | 0.4801 | 0.9984 | 0.0494 | True |
15
+ | learned | 0.0200 | 0.9319 | 0.9793 | 0.0341 | False |
16
+ | learned | 0.0500 | 0.6041 | 0.9968 | 0.0479 | True |
@@ -0,0 +1,5 @@
1
+ # Extraction quality (single-run baseline)
2
+
3
+ | precision | recall | f1 | exact_match | omission_rate | hallucination_rate |
4
+ |---|---|---|---|---|---|
5
+ | 0.8455 | 0.8163 | 0.8296 | 0.8123 | 0.0408 | 0.0056 |
@@ -0,0 +1,5 @@
1
+ # Grounding quality (consensus predictions)
2
+
3
+ | box_acc@0.5 | box_acc@0.7 | mean_iou | acc_grounded | acc_ungrounded | grounding_gap |
4
+ |---|---|---|---|---|---|
5
+ | 0.9967 | 0.9967 | 0.9975 | 0.9571 | 0.2500 | 0.7071 |
@@ -0,0 +1,10 @@
1
+ # Selective prediction by signal (raw confidence, test split)
2
+
3
+ | signal | e_aurc | auroc | coverage@2% | coverage@5% | e_aurc_ci |
4
+ |---|---|---|---|---|---|
5
+ | token_prob | 0.0414 | 0.8343 | 0.0182 | 0.8165 | [0.0237, 0.0621] |
6
+ | verbalized | 0.1183 | 0.5280 | 0.0000 | 0.0331 | [0.0864, 0.1527] |
7
+ | grounding | 0.0497 | 0.8752 | 0.0000 | 0.8893 | [0.0161, 0.0530] |
8
+ | consensus | 0.0426 | 0.6095 | 0.0000 | 1.0000 | [0.0177, 0.0529] |
9
+ | combined | 0.0340 | 0.6304 | 0.1624 | 1.0000 | [0.0154, 0.0572] |
10
+ | learned | 0.0352 | 0.6320 | 0.0892 | 1.0000 | [0.0174, 0.0571] |