verifydoc 0.1.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.
Files changed (45) hide show
  1. verifydoc/__init__.py +16 -0
  2. verifydoc/adapters/__init__.py +33 -0
  3. verifydoc/adapters/_ocr_common.py +80 -0
  4. verifydoc/adapters/api_vlm.py +97 -0
  5. verifydoc/adapters/base.py +31 -0
  6. verifydoc/adapters/docling.py +45 -0
  7. verifydoc/adapters/dots_ocr.py +66 -0
  8. verifydoc/adapters/mock.py +95 -0
  9. verifydoc/adapters/paddleocr_vl.py +50 -0
  10. verifydoc/adapters/text_search.py +53 -0
  11. verifydoc/calibration/__init__.py +20 -0
  12. verifydoc/calibration/base.py +56 -0
  13. verifydoc/calibration/conformal.py +75 -0
  14. verifydoc/calibration/histogram.py +34 -0
  15. verifydoc/calibration/isotonic.py +23 -0
  16. verifydoc/calibration/platt.py +30 -0
  17. verifydoc/calibration/splits.py +44 -0
  18. verifydoc/calibration/temperature.py +30 -0
  19. verifydoc/cli.py +48 -0
  20. verifydoc/confidence/__init__.py +18 -0
  21. verifydoc/confidence/combined.py +37 -0
  22. verifydoc/confidence/consensus.py +71 -0
  23. verifydoc/confidence/grounding_based.py +29 -0
  24. verifydoc/confidence/token_prob.py +43 -0
  25. verifydoc/confidence/verbalized.py +30 -0
  26. verifydoc/eval/__init__.py +1 -0
  27. verifydoc/eval/calibration.py +154 -0
  28. verifydoc/eval/extraction.py +534 -0
  29. verifydoc/eval/grounding.py +124 -0
  30. verifydoc/eval/harness.py +354 -0
  31. verifydoc/eval/selective.py +170 -0
  32. verifydoc/eval/stats.py +97 -0
  33. verifydoc/grounding/__init__.py +5 -0
  34. verifydoc/grounding/attach.py +87 -0
  35. verifydoc/ingest/__init__.py +5 -0
  36. verifydoc/ingest/loader.py +100 -0
  37. verifydoc/pipeline.py +134 -0
  38. verifydoc/policy/__init__.py +5 -0
  39. verifydoc/policy/abstention.py +73 -0
  40. verifydoc/types.py +230 -0
  41. verifydoc-0.1.0.dist-info/METADATA +374 -0
  42. verifydoc-0.1.0.dist-info/RECORD +45 -0
  43. verifydoc-0.1.0.dist-info/WHEEL +4 -0
  44. verifydoc-0.1.0.dist-info/entry_points.txt +2 -0
  45. verifydoc-0.1.0.dist-info/licenses/LICENSE +202 -0
verifydoc/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """VerifyDoc: a trust layer for document -> structured-JSON extraction."""
2
+
3
+ from verifydoc.pipeline import VerifiedResult, verify
4
+ from verifydoc.types import Document, FieldGold, FieldPrediction, Grounding, Schema
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "Document",
10
+ "FieldGold",
11
+ "FieldPrediction",
12
+ "Grounding",
13
+ "Schema",
14
+ "VerifiedResult",
15
+ "verify",
16
+ ]
@@ -0,0 +1,33 @@
1
+ """Extractor adapters. Golden rule #1: all model-specific code lives here.
2
+
3
+ Nothing outside this package may import a model SDK; adding a new extractor
4
+ is one new file implementing ``ExtractorAdapter``.
5
+ """
6
+
7
+ from verifydoc.adapters.base import ExtractorAdapter
8
+ from verifydoc.adapters.mock import MockAdapter
9
+ from verifydoc.adapters.text_search import TextSearchAdapter
10
+
11
+ __all__ = ["ExtractorAdapter", "MockAdapter", "TextSearchAdapter", "get_adapter"]
12
+
13
+ _REGISTRY = {
14
+ "mock": "verifydoc.adapters.mock:MockAdapter",
15
+ "text-search": "verifydoc.adapters.text_search:TextSearchAdapter",
16
+ "paddleocr-vl": "verifydoc.adapters.paddleocr_vl:PaddleOCRVLAdapter",
17
+ "dots-ocr": "verifydoc.adapters.dots_ocr:DotsOCRAdapter",
18
+ "docling": "verifydoc.adapters.docling:DoclingAdapter",
19
+ "api-vlm": "verifydoc.adapters.api_vlm:APIVLMAdapter",
20
+ }
21
+
22
+
23
+ def get_adapter(name: str, **kwargs: object) -> ExtractorAdapter:
24
+ """Instantiate a registered adapter by CLI name (lazy import)."""
25
+ import importlib
26
+ from typing import cast
27
+
28
+ if name not in _REGISTRY:
29
+ raise ValueError(f"unknown adapter {name!r}; available: {sorted(_REGISTRY)}")
30
+ module_name, _, class_name = _REGISTRY[name].partition(":")
31
+ module = importlib.import_module(module_name)
32
+ adapter_cls = getattr(module, class_name)
33
+ return cast(ExtractorAdapter, adapter_cls(**kwargs))
@@ -0,0 +1,80 @@
1
+ """Shared OCR-token plumbing for local OCR adapters (PaddleOCR-VL, dots.ocr).
2
+
3
+ OCR engines return (text, bbox, recognition-score) tokens per page. This
4
+ module clusters tokens into reading-order lines, builds a text-layer
5
+ ``Document``, and lets the shared field-finding heuristic run on top —
6
+ attaching each token's recognition score as ``token_logprobs`` so the
7
+ token-prob confidence signal works for local OCR models.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from dataclasses import dataclass
14
+
15
+ from verifydoc.adapters.text_search import TextSearchAdapter
16
+ from verifydoc.types import Document, FieldPrediction, Page, Schema, Word
17
+
18
+
19
+ @dataclass
20
+ class OCRToken:
21
+ """One recognized token: text, normalized bbox, recognition score in (0, 1]."""
22
+
23
+ text: str
24
+ bbox: tuple[float, float, float, float]
25
+ score: float
26
+ page: int = 0
27
+
28
+
29
+ def document_from_ocr_tokens(doc_id: str, tokens: list[OCRToken]) -> Document:
30
+ """Cluster OCR tokens into lines (by vertical overlap) and build a Document."""
31
+ pages: dict[int, list[OCRToken]] = {}
32
+ for tok in tokens:
33
+ pages.setdefault(tok.page, []).append(tok)
34
+
35
+ out_pages = []
36
+ for page_no in sorted(pages):
37
+ toks = sorted(pages[page_no], key=lambda t: (t.bbox[1], t.bbox[0]))
38
+ lines: list[list[OCRToken]] = []
39
+ for tok in toks:
40
+ placed = False
41
+ for line in lines:
42
+ y0 = sum(t.bbox[1] for t in line) / len(line)
43
+ y1 = sum(t.bbox[3] for t in line) / len(line)
44
+ mid = (tok.bbox[1] + tok.bbox[3]) / 2
45
+ if y0 <= mid <= y1:
46
+ line.append(tok)
47
+ placed = True
48
+ break
49
+ if not placed:
50
+ lines.append([tok])
51
+ for line in lines:
52
+ line.sort(key=lambda t: t.bbox[0])
53
+ text = "\n".join(" ".join(t.text for t in line) for line in lines)
54
+ words = [Word(text=t.text, bbox=t.bbox) for line in lines for t in line]
55
+ out_pages.append(Page(page=page_no, width=1.0, height=1.0, text=text, words=words))
56
+ return Document(doc_id=doc_id, pages=out_pages)
57
+
58
+
59
+ def predictions_from_ocr_tokens(
60
+ doc_id: str, tokens: list[OCRToken], schema: Schema
61
+ ) -> list[FieldPrediction]:
62
+ """Field predictions from OCR tokens: line clustering + label search +
63
+ per-field token log-scores for the token-prob confidence signal."""
64
+ doc = document_from_ocr_tokens(doc_id, tokens)
65
+ preds = TextSearchAdapter().extract(doc, schema)
66
+ by_text: dict[str, list[float]] = {}
67
+ for tok in tokens:
68
+ by_text.setdefault(tok.text.casefold(), []).append(tok.score)
69
+ out = []
70
+ for pred in preds:
71
+ logprobs = [
72
+ math.log(max(1e-6, min(1.0, by_text[part.casefold()][0])))
73
+ for part in str(pred.value).split()
74
+ if part.casefold() in by_text
75
+ ]
76
+ meta = dict(pred.meta)
77
+ if logprobs:
78
+ meta["token_logprobs"] = logprobs
79
+ out.append(pred.model_copy(update={"meta": meta}))
80
+ return out
@@ -0,0 +1,97 @@
1
+ """API-VLM adapter (comparison rows only; local models are the defaults).
2
+
3
+ Prompts a vision-language model API for schema-conforming JSON where every
4
+ leaf carries a 0-1 self-assessed confidence — the *verbalized* signal. The
5
+ client is injectable, so unit tests run with a fake and no network
6
+ (golden rule #5); without an injected client the Anthropic SDK is used.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any, Protocol
13
+
14
+ from verifydoc.adapters.base import ExtractorAdapter
15
+ from verifydoc.types import Document, FieldPrediction, Schema, flatten_json
16
+
17
+ _SYSTEM = (
18
+ "You extract structured data from documents. Reply with ONLY a JSON object "
19
+ "matching the requested schema, where every leaf value is replaced by "
20
+ '{"value": ..., "confidence": <float 0-1 honestly reflecting P(correct)>}.'
21
+ )
22
+
23
+
24
+ class CompletionClient(Protocol):
25
+ """Minimal client surface so tests can inject a fake."""
26
+
27
+ def complete(self, system: str, prompt: str) -> str: ...
28
+
29
+
30
+ class APIVLMAdapter(ExtractorAdapter):
31
+ name = "api-vlm"
32
+
33
+ def __init__(
34
+ self,
35
+ client: CompletionClient | None = None,
36
+ model: str = "claude-sonnet-5",
37
+ ) -> None:
38
+ self._client = client
39
+ self._model = model
40
+
41
+ def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]:
42
+ text = "\n\f\n".join(page.text or "" for page in doc.pages)
43
+ prompt = (
44
+ f"Schema (JSON Schema):\n{json.dumps(schema.raw)}\n\n"
45
+ f"Document text:\n{text}\n\n"
46
+ "Extract every schema field you can find."
47
+ )
48
+ raw = self._complete(_SYSTEM, prompt)
49
+ return self._parse(raw)
50
+
51
+ def _complete(self, system: str, prompt: str) -> str:
52
+ if self._client is not None:
53
+ return self._client.complete(system, prompt)
54
+ return self._anthropic_complete(system, prompt) # pragma: no cover - network
55
+
56
+ def _anthropic_complete(self, system: str, prompt: str) -> str: # pragma: no cover
57
+ try:
58
+ import anthropic
59
+ except ImportError as exc:
60
+ raise ImportError(
61
+ "APIVLMAdapter without an injected client requires: pip install anthropic"
62
+ ) from exc
63
+ response = anthropic.Anthropic().messages.create(
64
+ model=self._model,
65
+ max_tokens=4096,
66
+ system=system,
67
+ messages=[{"role": "user", "content": prompt}],
68
+ )
69
+ return str(response.content[0].text)
70
+
71
+ @staticmethod
72
+ def _parse(raw: str) -> list[FieldPrediction]:
73
+ """Parse {leaf: {value, confidence}} JSON into predictions."""
74
+ start, end = raw.find("{"), raw.rfind("}")
75
+ if start == -1 or end <= start:
76
+ return []
77
+ try:
78
+ obj: Any = json.loads(raw[start : end + 1])
79
+ except json.JSONDecodeError:
80
+ return []
81
+ flat = flatten_json(obj)
82
+ by_field: dict[str, dict[str, Any]] = {}
83
+ for path, value in flat.items():
84
+ if path.endswith(".value"):
85
+ by_field.setdefault(path[: -len(".value")], {})["value"] = value
86
+ elif path.endswith(".confidence"):
87
+ by_field.setdefault(path[: -len(".confidence")], {})["confidence"] = value
88
+ preds = []
89
+ for path, parts in by_field.items():
90
+ if "value" not in parts:
91
+ continue
92
+ verbalized = parts.get("confidence")
93
+ meta = {"verbalized_confidence": float(verbalized)} if verbalized is not None else {}
94
+ preds.append(
95
+ FieldPrediction(path=path, value=parts["value"], confidence=0.5, meta=meta)
96
+ )
97
+ return preds
@@ -0,0 +1,31 @@
1
+ """The ExtractorAdapter interface: extract(doc, schema) -> list[FieldPrediction]."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from verifydoc.types import Document, FieldPrediction, Schema
8
+
9
+
10
+ class ExtractorAdapter(ABC):
11
+ """Wraps one extraction model behind a model-agnostic interface.
12
+
13
+ Adapters emit raw predictions (values + whatever raw signals they have in
14
+ ``meta``); confidence, calibration, grounding, and abstention are applied
15
+ by later stages, never inside an adapter.
16
+ """
17
+
18
+ name: str = "adapter"
19
+
20
+ @abstractmethod
21
+ def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]:
22
+ """Extract one prediction per schema leaf found in the document."""
23
+
24
+ def extract_samples(
25
+ self, doc: Document, schema: Schema, k: int = 1
26
+ ) -> list[list[FieldPrediction]]:
27
+ """k independent runs for self-consistency; override when the model
28
+ supports cheaper native sampling (e.g. temperature > 0 decoding)."""
29
+ if k < 1:
30
+ raise ValueError("k must be >= 1")
31
+ return [self.extract(doc, schema) for _ in range(k)]
@@ -0,0 +1,45 @@
1
+ """Docling/MinerU output adapter: reuse a parse you already ran.
2
+
3
+ Consumes either a live ``docling`` conversion (SDK installed) or an exported
4
+ markdown/text file from Docling/MinerU/Marker, then runs the shared
5
+ field-finding heuristic over the parsed text.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from verifydoc.adapters.base import ExtractorAdapter
13
+ from verifydoc.adapters.text_search import TextSearchAdapter
14
+ from verifydoc.ingest import document_from_text
15
+ from verifydoc.types import Document, FieldPrediction, Schema
16
+
17
+
18
+ class DoclingAdapter(ExtractorAdapter):
19
+ name = "docling"
20
+
21
+ def __init__(self, parsed_output: str | Path | None = None) -> None:
22
+ """``parsed_output``: path to an exported markdown/text parse. When
23
+ omitted, the docling SDK is used to convert the source directly."""
24
+ self._parsed_output = Path(parsed_output) if parsed_output is not None else None
25
+
26
+ def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]:
27
+ if self._parsed_output is not None:
28
+ text = self._parsed_output.read_text(encoding="utf-8")
29
+ else: # pragma: no cover - needs docling SDK
30
+ text = self._convert_with_sdk(doc)
31
+ parsed_doc = document_from_text(doc.doc_id, text.split("\f"))
32
+ return TextSearchAdapter().extract(parsed_doc, schema)
33
+
34
+ @staticmethod
35
+ def _convert_with_sdk(doc: Document) -> str: # pragma: no cover - needs docling SDK
36
+ try:
37
+ from docling.document_converter import DocumentConverter
38
+ except ImportError as exc:
39
+ raise ImportError(
40
+ "DoclingAdapter without parsed_output requires docling: pip install docling"
41
+ ) from exc
42
+ if doc.source_path is None:
43
+ raise ValueError("document has no source_path for docling conversion")
44
+ result = DocumentConverter().convert(doc.source_path)
45
+ return str(result.document.export_to_markdown())
@@ -0,0 +1,66 @@
1
+ """dots.ocr adapter (local, single-GPU second extractor).
2
+
3
+ Requires the dots.ocr weights served via transformers; see
4
+ https://github.com/rednote-hilab/dots.ocr. SDK use is isolated here; parsed
5
+ layout tokens go through the shared ``_ocr_common`` pathway.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from verifydoc.adapters._ocr_common import OCRToken, predictions_from_ocr_tokens
13
+ from verifydoc.adapters.base import ExtractorAdapter
14
+ from verifydoc.types import Document, FieldPrediction, Schema
15
+
16
+ _PROMPT = "Please output the layout information from the image, including text and bbox."
17
+
18
+
19
+ class DotsOCRAdapter(ExtractorAdapter):
20
+ name = "dots-ocr"
21
+
22
+ def __init__(self, model_id: str = "rednote-hilab/dots.ocr") -> None: # pragma: no cover
23
+ try:
24
+ import torch # noqa: F401
25
+ from transformers import AutoModelForCausalLM, AutoProcessor
26
+ except ImportError as exc:
27
+ raise ImportError(
28
+ "DotsOCRAdapter requires torch + transformers: " "pip install torch transformers"
29
+ ) from exc
30
+ self._model = AutoModelForCausalLM.from_pretrained(
31
+ model_id, trust_remote_code=True, torch_dtype="auto", device_map="auto"
32
+ )
33
+ self._processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True)
34
+
35
+ def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]: # pragma: no cover
36
+ tokens: list[OCRToken] = []
37
+ for page in doc.pages:
38
+ if page.image_path is None:
39
+ continue
40
+ raw = self._generate(page.image_path)
41
+ for item in json.loads(raw):
42
+ x0, y0, x1, y1 = item["bbox"]
43
+ tokens.append(
44
+ OCRToken(
45
+ text=item.get("text", ""),
46
+ bbox=(
47
+ x0 / page.width,
48
+ y0 / page.height,
49
+ x1 / page.width,
50
+ y1 / page.height,
51
+ ),
52
+ score=float(item.get("score", 0.9)),
53
+ page=page.page,
54
+ )
55
+ )
56
+ return predictions_from_ocr_tokens(doc.doc_id, tokens, schema)
57
+
58
+ def _generate(self, image_path: str) -> str: # pragma: no cover
59
+ from PIL import Image
60
+
61
+ image = Image.open(image_path)
62
+ inputs = self._processor(text=_PROMPT, images=image, return_tensors="pt").to(
63
+ self._model.device
64
+ )
65
+ output = self._model.generate(**inputs, max_new_tokens=4096)
66
+ return str(self._processor.batch_decode(output, skip_special_tokens=True)[0])
@@ -0,0 +1,95 @@
1
+ """Mock adapter: canned or noisy predictions for tests and the synthetic benchmark.
2
+
3
+ No network, no model. Two modes:
4
+ - canned: a mapping of doc_id -> predictions, returned verbatim;
5
+ - noisy: given gold fields per doc, corrupt/omit/hallucinate at seeded rates
6
+ to simulate a realistic extractor for the benchmark harness.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import random
13
+
14
+ from verifydoc.adapters.base import ExtractorAdapter
15
+ from verifydoc.types import Document, FieldGold, FieldPrediction, Schema
16
+
17
+
18
+ class MockAdapter(ExtractorAdapter):
19
+ name = "mock"
20
+
21
+ def __init__(
22
+ self,
23
+ canned: dict[str, list[FieldPrediction]] | None = None,
24
+ gold: dict[str, list[FieldGold]] | None = None,
25
+ error_rate: float = 0.15,
26
+ omit_rate: float = 0.05,
27
+ hallucinate_rate: float = 0.05,
28
+ seed: int = 0,
29
+ ) -> None:
30
+ self._canned = canned or {}
31
+ self._gold = gold or {}
32
+ self.error_rate = error_rate
33
+ self.omit_rate = omit_rate
34
+ self.hallucinate_rate = hallucinate_rate
35
+ self._rng = random.Random(seed)
36
+
37
+ def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]:
38
+ if doc.doc_id in self._canned:
39
+ return [p.model_copy(deep=True) for p in self._canned[doc.doc_id]]
40
+ if doc.doc_id in self._gold:
41
+ return self._noisy(self._gold[doc.doc_id])
42
+ return []
43
+
44
+ def _noisy(self, golds: list[FieldGold]) -> list[FieldPrediction]:
45
+ rng = self._rng
46
+ preds: list[FieldPrediction] = []
47
+ for gold in golds:
48
+ roll = rng.random()
49
+ if roll < self.omit_rate:
50
+ continue
51
+ correct = roll >= self.omit_rate + self.error_rate
52
+ value = gold.value if correct else self._corrupt(gold)
53
+ preds.append(
54
+ FieldPrediction(
55
+ path=gold.path, value=value, confidence=0.5, meta=self._signals(correct)
56
+ )
57
+ )
58
+ if golds and rng.random() < self.hallucinate_rate:
59
+ preds.append(
60
+ FieldPrediction(
61
+ path=f"spurious_{rng.randrange(1000)}",
62
+ value="ghost",
63
+ confidence=0.5,
64
+ meta=self._signals(False),
65
+ )
66
+ )
67
+ return preds
68
+
69
+ def _signals(self, correct: bool) -> dict[str, object]:
70
+ """Simulated raw signals with realistic pathologies: verbalized is
71
+ inflated regardless of correctness (the RLHF failure mode); token
72
+ scores carry weak-but-real signal."""
73
+ rng = self._rng
74
+ verbalized = min(1.0, max(0.0, 0.9 + rng.uniform(-0.05, 0.08)))
75
+ base = 0.97 if correct else 0.90
76
+ logprobs = [math.log(min(1.0, max(0.05, base + rng.gauss(0.0, 0.03)))) for _ in range(3)]
77
+ return {"verbalized_confidence": verbalized, "token_logprobs": logprobs}
78
+
79
+ def _corrupt(self, gold: FieldGold) -> object:
80
+ """Silently-wrong value: digit swaps for numbers, char noise for text."""
81
+ rng = self._rng
82
+ text = str(gold.value)
83
+ if gold.scoring == "numeric":
84
+ digits = [ch for ch in text if ch.isdigit()]
85
+ if len(digits) >= 2:
86
+ chars = list(text)
87
+ positions = [i for i, ch in enumerate(chars) if ch.isdigit()]
88
+ i, j = rng.sample(positions, 2)
89
+ chars[i], chars[j] = chars[j], chars[i]
90
+ return "".join(chars)
91
+ return text + "0"
92
+ if len(text) >= 2:
93
+ i = rng.randrange(len(text) - 1)
94
+ return text[:i] + text[i + 1] + text[i] + text[i + 2 :]
95
+ return text + "x"
@@ -0,0 +1,50 @@
1
+ """PaddleOCR-VL adapter (local, single-GPU default extractor).
2
+
3
+ Requires ``pip install paddleocr`` and a GPU (or slow CPU). All SDK use is
4
+ isolated here; the token -> field mapping is the shared, unit-tested
5
+ ``_ocr_common`` pathway.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from verifydoc.adapters._ocr_common import OCRToken, predictions_from_ocr_tokens
11
+ from verifydoc.adapters.base import ExtractorAdapter
12
+ from verifydoc.types import Document, FieldPrediction, Schema
13
+
14
+
15
+ class PaddleOCRVLAdapter(ExtractorAdapter):
16
+ name = "paddleocr-vl"
17
+
18
+ def __init__(self, lang: str = "en") -> None: # pragma: no cover - needs SDK
19
+ try:
20
+ from paddleocr import PaddleOCR
21
+ except ImportError as exc:
22
+ raise ImportError(
23
+ "PaddleOCRVLAdapter requires the paddleocr package: pip install paddleocr"
24
+ ) from exc
25
+ self._ocr = PaddleOCR(use_angle_cls=True, lang=lang, show_log=False)
26
+
27
+ def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]: # pragma: no cover
28
+ tokens: list[OCRToken] = []
29
+ for page in doc.pages:
30
+ if page.image_path is None:
31
+ continue
32
+ result = self._ocr.ocr(page.image_path, cls=True)
33
+ for line in result[0] or []:
34
+ quad, (text, score) = line
35
+ xs = [pt[0] for pt in quad]
36
+ ys = [pt[1] for pt in quad]
37
+ tokens.append(
38
+ OCRToken(
39
+ text=text,
40
+ bbox=(
41
+ min(xs) / page.width,
42
+ min(ys) / page.height,
43
+ max(xs) / page.width,
44
+ max(ys) / page.height,
45
+ ),
46
+ score=float(score),
47
+ page=page.page,
48
+ )
49
+ )
50
+ return predictions_from_ocr_tokens(doc.doc_id, tokens, schema)
@@ -0,0 +1,53 @@
1
+ """Dependency-free heuristic extractor: label/value search over the text layer.
2
+
3
+ The honest floor baseline: for each schema leaf, find a line whose text
4
+ contains the leaf's label ("invoice_id" -> "invoice id") and take what follows
5
+ the separator as the value. Real OCR/VLM adapters reuse this field-finding on
6
+ top of their own text layers; the paper reports it as the no-model baseline.
7
+
8
+ # DECISION: the heuristic baseline skips array leaves (``items[].price``) —
9
+ # repeating-group detection is extractor territory, not a text heuristic's;
10
+ # array handling is exercised through the mock and model adapters.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from verifydoc.adapters.base import ExtractorAdapter
16
+ from verifydoc.types import Document, FieldPrediction, Schema
17
+
18
+
19
+ class TextSearchAdapter(ExtractorAdapter):
20
+ name = "text-search"
21
+
22
+ def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]:
23
+ preds: list[FieldPrediction] = []
24
+ for leaf in schema.leaves:
25
+ if "[]" in leaf.path:
26
+ continue
27
+ label = leaf.path.split(".")[-1].replace("_", " ").casefold()
28
+ found = self._find(doc, label)
29
+ if found is not None:
30
+ value, page_no, line = found
31
+ preds.append(
32
+ FieldPrediction(
33
+ path=leaf.path,
34
+ value=value,
35
+ confidence=0.5,
36
+ meta={"source_page": page_no, "source_line": line},
37
+ )
38
+ )
39
+ return preds
40
+
41
+ @staticmethod
42
+ def _find(doc: Document, label: str) -> tuple[str, int, str] | None:
43
+ for page in doc.pages:
44
+ if not page.text:
45
+ continue
46
+ for line in page.text.split("\n"):
47
+ idx = line.casefold().find(label)
48
+ if idx == -1:
49
+ continue
50
+ value = line[idx + len(label) :].lstrip(" \t:—–-").strip()
51
+ if value:
52
+ return value, page.page, line
53
+ return None
@@ -0,0 +1,20 @@
1
+ """Post-hoc calibrators (PROJECT.md §5.G), fit ONLY on the calibration split."""
2
+
3
+ from verifydoc.calibration.base import Calibrator
4
+ from verifydoc.calibration.conformal import ConformalAbstention
5
+ from verifydoc.calibration.histogram import HistogramBinning
6
+ from verifydoc.calibration.isotonic import IsotonicCalibrator
7
+ from verifydoc.calibration.platt import PlattScaling
8
+ from verifydoc.calibration.splits import assert_disjoint, split_calibration
9
+ from verifydoc.calibration.temperature import TemperatureScaling
10
+
11
+ __all__ = [
12
+ "Calibrator",
13
+ "ConformalAbstention",
14
+ "HistogramBinning",
15
+ "IsotonicCalibrator",
16
+ "PlattScaling",
17
+ "TemperatureScaling",
18
+ "assert_disjoint",
19
+ "split_calibration",
20
+ ]
@@ -0,0 +1,56 @@
1
+ """Calibrator interface: fit on the calibration split, transform anywhere else.
2
+
3
+ Golden rule #4: calibration parameters are NEVER tuned on test data. Fit takes
4
+ the calibration split's (confidence, correctness) pairs; transform maps raw
5
+ confidences to calibrated ones.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from collections.abc import Sequence
12
+
13
+ import numpy as np
14
+
15
+ _EPS = 1e-7
16
+
17
+
18
+ class Calibrator(ABC):
19
+ """Post-hoc mapping from raw confidence to calibrated probability."""
20
+
21
+ _fitted: bool = False
22
+
23
+ def fit(self, conf: Sequence[float], correct: Sequence[int]) -> Calibrator:
24
+ c = np.asarray(conf, dtype=float)
25
+ y = np.asarray(correct, dtype=float)
26
+ if c.shape != y.shape or c.ndim != 1 or c.size == 0:
27
+ raise ValueError("conf and correct must be 1-D, equal-length, non-empty")
28
+ if ((c < 0) | (c > 1)).any():
29
+ raise ValueError("confidences must lie in [0, 1]")
30
+ self._fit(c, y)
31
+ self._fitted = True
32
+ return self
33
+
34
+ def transform(self, conf: Sequence[float]) -> np.ndarray:
35
+ if not self._fitted:
36
+ raise RuntimeError(f"{type(self).__name__} must be fit on the calibration split first")
37
+ c = np.asarray(conf, dtype=float)
38
+ return np.clip(self._transform(c), 0.0, 1.0)
39
+
40
+ @abstractmethod
41
+ def _fit(self, conf: np.ndarray, correct: np.ndarray) -> None: ...
42
+
43
+ @abstractmethod
44
+ def _transform(self, conf: np.ndarray) -> np.ndarray: ...
45
+
46
+
47
+ def logit(p: np.ndarray) -> np.ndarray:
48
+ """Log-odds with clipping so 0/1 confidences stay finite."""
49
+ p = np.clip(p, _EPS, 1.0 - _EPS)
50
+ return np.log(p / (1.0 - p))
51
+
52
+
53
+ def sigmoid(z: np.ndarray) -> np.ndarray:
54
+ from scipy.special import expit # numerically stable for extreme log-odds
55
+
56
+ return np.asarray(expit(z), dtype=float)