parse-bench 1.0.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.
- parse_bench/__init__.py +3 -0
- parse_bench/analysis/__init__.py +6 -0
- parse_bench/analysis/aggregation_report.py +582 -0
- parse_bench/analysis/cli.py +472 -0
- parse_bench/analysis/comparison.py +382 -0
- parse_bench/analysis/comparison_core.py +357 -0
- parse_bench/analysis/comparison_report.py +2066 -0
- parse_bench/analysis/detailed_report.py +2254 -0
- parse_bench/analysis/leaderboard_report.py +852 -0
- parse_bench/analysis/metric_definitions.py +771 -0
- parse_bench/cli.py +267 -0
- parse_bench/data/__init__.py +1 -0
- parse_bench/data/cli.py +118 -0
- parse_bench/data/download.py +127 -0
- parse_bench/evaluation/__init__.py +11 -0
- parse_bench/evaluation/cli.py +435 -0
- parse_bench/evaluation/evaluators/__init__.py +17 -0
- parse_bench/evaluation/evaluators/base.py +34 -0
- parse_bench/evaluation/evaluators/extract.py +429 -0
- parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
- parse_bench/evaluation/evaluators/parse.py +1353 -0
- parse_bench/evaluation/evaluators/qa.py +199 -0
- parse_bench/evaluation/layout_adapters/__init__.py +21 -0
- parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
- parse_bench/evaluation/layout_adapters/base.py +105 -0
- parse_bench/evaluation/layout_adapters/registry.py +109 -0
- parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
- parse_bench/evaluation/layout_label_mappers/base.py +66 -0
- parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
- parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
- parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
- parse_bench/evaluation/metric_aggregation.py +56 -0
- parse_bench/evaluation/metrics/__init__.py +5 -0
- parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
- parse_bench/evaluation/metrics/attribution/constants.py +12 -0
- parse_bench/evaluation/metrics/attribution/core.py +1108 -0
- parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
- parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
- parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
- parse_bench/evaluation/metrics/base.py +33 -0
- parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
- parse_bench/evaluation/metrics/extract/__init__.py +29 -0
- parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
- parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
- parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
- parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
- parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
- parse_bench/evaluation/metrics/extract/test_types.py +11 -0
- parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
- parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
- parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
- parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
- parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
- parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
- parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
- parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
- parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
- parse_bench/evaluation/metrics/parse/__init__.py +5 -0
- parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
- parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
- parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
- parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
- parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
- parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
- parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
- parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
- parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
- parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
- parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
- parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
- parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
- parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
- parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
- parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
- parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
- parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
- parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
- parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
- parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
- parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
- parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
- parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
- parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
- parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
- parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
- parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
- parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
- parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
- parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
- parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
- parse_bench/evaluation/metrics/parse/test_types.py +103 -0
- parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
- parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
- parse_bench/evaluation/metrics/parse/utils.py +885 -0
- parse_bench/evaluation/metrics/qa/__init__.py +5 -0
- parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
- parse_bench/evaluation/qa/__init__.py +5 -0
- parse_bench/evaluation/qa/llm_service.py +335 -0
- parse_bench/evaluation/reports/__init__.py +8 -0
- parse_bench/evaluation/reports/csv.py +64 -0
- parse_bench/evaluation/reports/html.py +338 -0
- parse_bench/evaluation/reports/markdown.py +98 -0
- parse_bench/evaluation/reports/rule_csv.py +22 -0
- parse_bench/evaluation/runner.py +1864 -0
- parse_bench/evaluation/stats.py +104 -0
- parse_bench/extensions.py +72 -0
- parse_bench/inference/__init__.py +33 -0
- parse_bench/inference/chunkr_layout_extraction.py +160 -0
- parse_bench/inference/cli.py +484 -0
- parse_bench/inference/layout_extraction.py +422 -0
- parse_bench/inference/pipelines/__init__.py +59 -0
- parse_bench/inference/pipelines/extract.py +39 -0
- parse_bench/inference/pipelines/layout.py +142 -0
- parse_bench/inference/pipelines/parse.py +2603 -0
- parse_bench/inference/pipelines.py +0 -0
- parse_bench/inference/providers/__init__.py +28 -0
- parse_bench/inference/providers/base.py +196 -0
- parse_bench/inference/providers/cancellation.py +137 -0
- parse_bench/inference/providers/extract/__init__.py +22 -0
- parse_bench/inference/providers/extract/citations.py +549 -0
- parse_bench/inference/providers/extract/extend.py +851 -0
- parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
- parse_bench/inference/providers/layoutdet/__init__.py +25 -0
- parse_bench/inference/providers/layoutdet/adapters.py +946 -0
- parse_bench/inference/providers/layoutdet/base.py +203 -0
- parse_bench/inference/providers/layoutdet/chandra.py +449 -0
- parse_bench/inference/providers/layoutdet/docling.py +125 -0
- parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
- parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
- parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
- parse_bench/inference/providers/layoutdet/paddle.py +117 -0
- parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
- parse_bench/inference/providers/layoutdet/surya.py +250 -0
- parse_bench/inference/providers/layoutdet/yolo.py +109 -0
- parse_bench/inference/providers/parse/__init__.py +64 -0
- parse_bench/inference/providers/parse/_docling_common.py +233 -0
- parse_bench/inference/providers/parse/_layout_utils.py +611 -0
- parse_bench/inference/providers/parse/amazon_nova.py +515 -0
- parse_bench/inference/providers/parse/anthropic.py +882 -0
- parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
- parse_bench/inference/providers/parse/chandra2.py +633 -0
- parse_bench/inference/providers/parse/chunkr.py +268 -0
- parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
- parse_bench/inference/providers/parse/datalab.py +370 -0
- parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
- parse_bench/inference/providers/parse/docling.py +281 -0
- parse_bench/inference/providers/parse/docling_serve.py +289 -0
- parse_bench/inference/providers/parse/dots_ocr.py +574 -0
- parse_bench/inference/providers/parse/extend_parse.py +710 -0
- parse_bench/inference/providers/parse/falconocr.py +436 -0
- parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
- parse_bench/inference/providers/parse/gemma4.py +472 -0
- parse_bench/inference/providers/parse/glm_zai.py +229 -0
- parse_bench/inference/providers/parse/google.py +1125 -0
- parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
- parse_bench/inference/providers/parse/google_docai.py +776 -0
- parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
- parse_bench/inference/providers/parse/granite_vision.py +515 -0
- parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
- parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
- parse_bench/inference/providers/parse/landingai.py +452 -0
- parse_bench/inference/providers/parse/liteparse.py +350 -0
- parse_bench/inference/providers/parse/llamaparse.py +677 -0
- parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
- parse_bench/inference/providers/parse/markitdown.py +138 -0
- parse_bench/inference/providers/parse/mineru25.py +405 -0
- parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
- parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
- parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
- parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
- parse_bench/inference/providers/parse/oi_parser.py +222 -0
- parse_bench/inference/providers/parse/openai.py +740 -0
- parse_bench/inference/providers/parse/opendataloader.py +152 -0
- parse_bench/inference/providers/parse/paddleocr.py +624 -0
- parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
- parse_bench/inference/providers/parse/pulse.py +785 -0
- parse_bench/inference/providers/parse/pymupdf.py +207 -0
- parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
- parse_bench/inference/providers/parse/pypdf.py +179 -0
- parse_bench/inference/providers/parse/qwen.py +678 -0
- parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
- parse_bench/inference/providers/parse/reducto.py +546 -0
- parse_bench/inference/providers/parse/surya2.py +372 -0
- parse_bench/inference/providers/parse/tesseract.py +301 -0
- parse_bench/inference/providers/parse/textract.py +694 -0
- parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
- parse_bench/inference/providers/parse/unstructured.py +485 -0
- parse_bench/inference/providers/parse/warp_ingest.py +199 -0
- parse_bench/inference/providers/registry.py +49 -0
- parse_bench/inference/renormalize.py +170 -0
- parse_bench/inference/runner.py +2023 -0
- parse_bench/layout_label_mapping.py +424 -0
- parse_bench/layout_projection.py +179 -0
- parse_bench/pipeline/__init__.py +1 -0
- parse_bench/pipeline/cli.py +549 -0
- parse_bench/schemas/__init__.py +33 -0
- parse_bench/schemas/evaluation.py +93 -0
- parse_bench/schemas/extract_output.py +36 -0
- parse_bench/schemas/layout_detection_output.py +545 -0
- parse_bench/schemas/layout_ontology.py +315 -0
- parse_bench/schemas/metrics.py +69 -0
- parse_bench/schemas/parse_output.py +152 -0
- parse_bench/schemas/pipeline.py +22 -0
- parse_bench/schemas/pipeline_io.py +106 -0
- parse_bench/schemas/product.py +97 -0
- parse_bench/test_cases/__init__.py +25 -0
- parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
- parse_bench/test_cases/extract_field_paths.py +164 -0
- parse_bench/test_cases/layout_attribution_generation.py +287 -0
- parse_bench/test_cases/loader.py +652 -0
- parse_bench/test_cases/parse_rule_schemas.py +1071 -0
- parse_bench/test_cases/rule_filters.py +32 -0
- parse_bench/test_cases/rule_ids.py +107 -0
- parse_bench/test_cases/schema.py +427 -0
- parse_bench/utils/__init__.py +15 -0
- parse_bench/utils/gemini_layout_utils.py +670 -0
- parse_bench/utils/text_aggregation.py +100 -0
- parse_bench-1.0.0.dist-info/METADATA +476 -0
- parse_bench-1.0.0.dist-info/RECORD +227 -0
- parse_bench-1.0.0.dist-info/WHEEL +4 -0
- parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
- parse_bench-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Delimiter-run pairing for the markdown emphasis detectors.
|
|
2
|
+
|
|
3
|
+
The ``**`` / ``*`` / ``_`` / ``~~`` arms of ``FormattingRule`` used to be
|
|
4
|
+
regexes guarded by CommonMark flanking rules. Flanking is a *local* test, and
|
|
5
|
+
two requirements proved locally indistinguishable: whitespace-padded emphasis
|
|
6
|
+
(``Grand ** Total ** shown below.``) must count as emphasis, while the same
|
|
7
|
+
characters seen between two padded spans (``** Alpha ** plain ** Beta **``)
|
|
8
|
+
must not let ``plain`` score. Around a single delimiter run those contexts are
|
|
9
|
+
identical - only *which run pairs with which* separates them - so this module
|
|
10
|
+
resolves emphasis the way a parser does: tokenize delimiter runs, pair openers
|
|
11
|
+
with closers, and let the caller search query text inside the paired spans.
|
|
12
|
+
|
|
13
|
+
Pairing happens in two passes over each kind's runs:
|
|
14
|
+
|
|
15
|
+
1. **Strict pass** - runs are classified with CommonMark's flanking rules
|
|
16
|
+
(including the punctuation nuances and the no-intraword restriction for
|
|
17
|
+
``_``) and paired on a stack, nearest-opener-first. This resolves every
|
|
18
|
+
tightly-delimited span exactly like a markdown parser would, and keeps a
|
|
19
|
+
literal run such as the ``**`` in ``2**3`` from stealing an opener.
|
|
20
|
+
2. **Padded pass** - the runs left unpaired keep any strict capability they
|
|
21
|
+
had and additionally may open or close across *inner* padding, provided the
|
|
22
|
+
padded side's immediate neighbor really is whitespace. This is the
|
|
23
|
+
evaluator's one deliberate departure from CommonMark: parser output often
|
|
24
|
+
pads just inside the markers (``** Total **``, ``**Total **:``) while still
|
|
25
|
+
meaning emphasis, so padded shapes score exactly like tight ones. A padded
|
|
26
|
+
pairing whose content range would swallow a strict-pass span is discarded:
|
|
27
|
+
two padded runs bracketing a real span (``** foo **bold** bar **``) are
|
|
28
|
+
that span's neighbors, not its padding, and letting them pair would score
|
|
29
|
+
the plain text around it.
|
|
30
|
+
|
|
31
|
+
Both passes drop stack openers once a blank line intervenes: markdown emphasis
|
|
32
|
+
cannot cross a paragraph break, whatever the line endings.
|
|
33
|
+
|
|
34
|
+
Padding may wrap a single line break for ``**`` and ``~~`` (a closer on its own
|
|
35
|
+
line, ``**Total\\n**``, still closes) but stays horizontal for ``*`` and ``_``:
|
|
36
|
+
a line-leading ``*`` is usually a bullet, and letting it reach across the line
|
|
37
|
+
break would pair one list item's marker with the next and italicize the item.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import re
|
|
43
|
+
import string
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
|
|
46
|
+
# A paragraph break with LF or CRLF endings, possibly with trailing whitespace
|
|
47
|
+
# on the blank line.
|
|
48
|
+
_BLANK_LINE_RE = re.compile(r"\r?\n[ \t]*\r?\n")
|
|
49
|
+
|
|
50
|
+
# ASCII punctuation, per CommonMark's flanking definitions. Parser output is
|
|
51
|
+
# overwhelmingly ASCII; Unicode punctuation is treated as an ordinary letter.
|
|
52
|
+
_PUNCT = frozenset(string.punctuation)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class _KindSpec:
|
|
57
|
+
char: str
|
|
58
|
+
exact_length: int | None # run must be exactly this long (None: 2 or more)
|
|
59
|
+
underscore_rule: bool # apply CommonMark's no-intraword restriction
|
|
60
|
+
padding_may_break_line: bool
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_KIND_SPECS = {
|
|
64
|
+
"bold": _KindSpec("*", None, False, True),
|
|
65
|
+
"italic_star": _KindSpec("*", 1, False, False),
|
|
66
|
+
"italic_under": _KindSpec("_", 1, True, False),
|
|
67
|
+
"strikeout": _KindSpec("~", None, False, True),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class _Run:
|
|
73
|
+
start: int
|
|
74
|
+
end: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _find_runs(md: str, spec: _KindSpec) -> list[_Run]:
|
|
78
|
+
runs = []
|
|
79
|
+
for match in re.finditer(re.escape(spec.char) + "+", md):
|
|
80
|
+
length = match.end() - match.start()
|
|
81
|
+
if spec.exact_length is not None:
|
|
82
|
+
if length != spec.exact_length:
|
|
83
|
+
continue
|
|
84
|
+
elif length < 2:
|
|
85
|
+
continue
|
|
86
|
+
runs.append(_Run(match.start(), match.end()))
|
|
87
|
+
return runs
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_ws(char: str | None) -> bool:
|
|
91
|
+
# String edges count as whitespace, as in CommonMark.
|
|
92
|
+
return char is None or char.isspace()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _is_punct(char: str | None) -> bool:
|
|
96
|
+
return char is not None and char in _PUNCT
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _strict_caps(md: str, run: _Run, spec: _KindSpec) -> tuple[bool, bool]:
|
|
100
|
+
"""(can_open, can_close) under CommonMark flanking rules."""
|
|
101
|
+
prev = md[run.start - 1] if run.start > 0 else None
|
|
102
|
+
nxt = md[run.end] if run.end < len(md) else None
|
|
103
|
+
|
|
104
|
+
left_flanking = not _is_ws(nxt) and (not _is_punct(nxt) or _is_ws(prev) or _is_punct(prev))
|
|
105
|
+
right_flanking = not _is_ws(prev) and (not _is_punct(prev) or _is_ws(nxt) or _is_punct(nxt))
|
|
106
|
+
|
|
107
|
+
if not spec.underscore_rule:
|
|
108
|
+
return left_flanking, right_flanking
|
|
109
|
+
# Underscores do not open or close intraword emphasis (snake_case).
|
|
110
|
+
can_open = left_flanking and (not right_flanking or _is_punct(prev))
|
|
111
|
+
can_close = right_flanking and (not left_flanking or _is_punct(nxt))
|
|
112
|
+
return can_open, can_close
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _padded_after(md: str, pos: int, may_break_line: bool) -> bool:
|
|
116
|
+
"""Non-whitespace follows *pos* after inner padding (never a blank line)."""
|
|
117
|
+
i = pos
|
|
118
|
+
while i < len(md) and md[i] in " \t":
|
|
119
|
+
i += 1
|
|
120
|
+
if may_break_line and i < len(md) and md[i] in "\r\n":
|
|
121
|
+
if md[i] == "\r":
|
|
122
|
+
i += 1
|
|
123
|
+
if i < len(md) and md[i] == "\n":
|
|
124
|
+
i += 1
|
|
125
|
+
while i < len(md) and md[i] in " \t":
|
|
126
|
+
i += 1
|
|
127
|
+
return i < len(md) and not md[i].isspace()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _padded_before(md: str, pos: int, may_break_line: bool) -> bool:
|
|
131
|
+
"""Non-whitespace precedes *pos* before inner padding (never a blank line)."""
|
|
132
|
+
i = pos - 1
|
|
133
|
+
while i >= 0 and md[i] in " \t":
|
|
134
|
+
i -= 1
|
|
135
|
+
if may_break_line and i >= 0 and md[i] == "\n":
|
|
136
|
+
i -= 1
|
|
137
|
+
if i >= 0 and md[i] == "\r":
|
|
138
|
+
i -= 1
|
|
139
|
+
while i >= 0 and md[i] in " \t":
|
|
140
|
+
i -= 1
|
|
141
|
+
return i >= 0 and not md[i].isspace()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _padded_caps(md: str, run: _Run, spec: _KindSpec, strict: tuple[bool, bool]) -> tuple[bool, bool]:
|
|
145
|
+
"""Strict capabilities carried forward, plus genuinely padded sides.
|
|
146
|
+
|
|
147
|
+
A padded capability requires actual padding - the immediate neighbor must
|
|
148
|
+
be whitespace. Without that gate this pass would re-grant capabilities the
|
|
149
|
+
strict rules deliberately withheld (an intraword ``_``, for example).
|
|
150
|
+
"""
|
|
151
|
+
strict_open, strict_close = strict
|
|
152
|
+
prev = md[run.start - 1] if run.start > 0 else None
|
|
153
|
+
nxt = md[run.end] if run.end < len(md) else None
|
|
154
|
+
can_open = strict_open or (
|
|
155
|
+
nxt is not None and nxt.isspace() and _padded_after(md, run.end, spec.padding_may_break_line)
|
|
156
|
+
)
|
|
157
|
+
can_close = strict_close or (
|
|
158
|
+
prev is not None and prev.isspace() and _padded_before(md, run.start, spec.padding_may_break_line)
|
|
159
|
+
)
|
|
160
|
+
return can_open, can_close
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _pair(
|
|
164
|
+
md: str,
|
|
165
|
+
runs: list[_Run],
|
|
166
|
+
caps: dict[int, tuple[bool, bool]],
|
|
167
|
+
blank_line_starts: list[int],
|
|
168
|
+
) -> tuple[list[tuple[int, int]], list[_Run]]:
|
|
169
|
+
"""Stack-pair *runs*, closing against the nearest open run first.
|
|
170
|
+
|
|
171
|
+
A run that can both open and close acts as a closer whenever an opener is
|
|
172
|
+
on the stack, as in CommonMark. Returns the paired spans as (start, end)
|
|
173
|
+
content ranges plus the runs left unpaired.
|
|
174
|
+
"""
|
|
175
|
+
spans: list[tuple[int, int]] = []
|
|
176
|
+
stack: list[_Run] = []
|
|
177
|
+
paired: set[int] = set()
|
|
178
|
+
for run in runs:
|
|
179
|
+
# Emphasis cannot cross a paragraph break: openers left of one are dead.
|
|
180
|
+
# Openers sit in stack order, so one blank line strands all of them.
|
|
181
|
+
if stack and _has_blank_between(blank_line_starts, stack[-1].end, run.start):
|
|
182
|
+
stack.clear()
|
|
183
|
+
can_open, can_close = caps[run.start]
|
|
184
|
+
if can_close and stack:
|
|
185
|
+
opener = stack.pop()
|
|
186
|
+
spans.append((opener.end, run.start))
|
|
187
|
+
paired.add(opener.start)
|
|
188
|
+
paired.add(run.start)
|
|
189
|
+
elif can_open:
|
|
190
|
+
stack.append(run)
|
|
191
|
+
unpaired = [run for run in runs if run.start not in paired]
|
|
192
|
+
return spans, unpaired
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _has_blank_between(blank_line_starts: list[int], lo: int, hi: int) -> bool:
|
|
196
|
+
import bisect
|
|
197
|
+
|
|
198
|
+
idx = bisect.bisect_left(blank_line_starts, lo)
|
|
199
|
+
return idx < len(blank_line_starts) and blank_line_starts[idx] < hi
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def emphasis_spans(md: str, kind: str) -> list[tuple[int, int]]:
|
|
203
|
+
"""Content ranges of every *kind* emphasis span in *md*, in order."""
|
|
204
|
+
spec = _KIND_SPECS[kind]
|
|
205
|
+
runs = _find_runs(md, spec)
|
|
206
|
+
if len(runs) < 2:
|
|
207
|
+
return []
|
|
208
|
+
blank_line_starts = [match.start() for match in _BLANK_LINE_RE.finditer(md)]
|
|
209
|
+
|
|
210
|
+
strict = {run.start: _strict_caps(md, run, spec) for run in runs}
|
|
211
|
+
spans, leftover = _pair(md, runs, strict, blank_line_starts)
|
|
212
|
+
padded = {run.start: _padded_caps(md, run, spec, strict[run.start]) for run in leftover}
|
|
213
|
+
more, _ = _pair(md, leftover, padded, blank_line_starts)
|
|
214
|
+
# Two padded runs bracketing a strict span pair into a wrapper that would
|
|
215
|
+
# score the plain text around that span. Bracketing is not padding: drop
|
|
216
|
+
# any padded span that contains a strict one.
|
|
217
|
+
more = [padded_span for padded_span in more if not _contains_any(padded_span, spans)]
|
|
218
|
+
return sorted(spans + more)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _contains_any(outer: tuple[int, int], spans: list[tuple[int, int]]) -> bool:
|
|
222
|
+
return any(outer[0] <= start and end <= outer[1] for start, end in spans)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class EmphasisSpanMatcher:
|
|
226
|
+
"""Searches for a query pattern inside paired emphasis spans.
|
|
227
|
+
|
|
228
|
+
Duck-types the one method ``FormattingRule.run`` uses on its compiled
|
|
229
|
+
patterns, so a matcher can sit in the same detector list as the tag-pair
|
|
230
|
+
and heading regexes.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def __init__(self, kinds: tuple[str, ...], query: str):
|
|
234
|
+
self._kinds = kinds
|
|
235
|
+
self._query = re.compile(query, re.IGNORECASE)
|
|
236
|
+
|
|
237
|
+
def search(self, content: str) -> bool:
|
|
238
|
+
return any(
|
|
239
|
+
self._query.search(content[start:end])
|
|
240
|
+
for kind in self._kinds
|
|
241
|
+
for start, end in emphasis_spans(content, kind)
|
|
242
|
+
)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Fast, EXACT-parity tree-edit distance for TEDS — a drop-in for APTED.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
The TEDS metric (``teds_metric.py``) scores an HTML table by the tree-edit
|
|
6
|
+
distance between two ``TableTree`` forests. That distance is the dominant cost
|
|
7
|
+
of the whole table-scoring path and grows ``~O(cells^2)`` — on a large table
|
|
8
|
+
(e.g. 40x12) a single ``teds`` variant can take well over a second, and the
|
|
9
|
+
metric runs three variants (``teds`` / ``teds_struct`` / ``teds_struct_bool``)
|
|
10
|
+
on every predicted table of every document in the benchmark.
|
|
11
|
+
|
|
12
|
+
The distance itself is computed by the pure-Python ``apted`` package. Profiling
|
|
13
|
+
(see ``FAST_TABLE_OPTIMIZATION.md`` in the training pipeline) showed two things:
|
|
14
|
+
|
|
15
|
+
1. APTED's dynamic program is the bottleneck, and it **recomputes the per-node
|
|
16
|
+
rename cost several times** instead of memoizing it.
|
|
17
|
+
2. The trees TEDS builds are ordinary *ordered* forests (``table -> tr* -> td*``,
|
|
18
|
+
with inline markup already tokenized into cell content). For an ordered tree
|
|
19
|
+
the classic **Zhang-Shasha** algorithm computes the **identical optimal
|
|
20
|
+
tree-edit distance** as APTED — it is the same quantity, so the result is
|
|
21
|
+
equal *by construction*, not by approximation.
|
|
22
|
+
|
|
23
|
+
This module implements Zhang-Shasha over the existing ``TableTree`` nodes and
|
|
24
|
+
reuses each variant's own ``apted.Config.rename`` for the substitution cost, so
|
|
25
|
+
the numbers are byte-for-byte the APTED numbers (modulo floating-point
|
|
26
|
+
summation order, which is bounded by ~1e-12 and never large enough to move a
|
|
27
|
+
reported TEDS score). Insertion/deletion cost is ``1`` — exactly APTED's
|
|
28
|
+
default, which ParseBench's configs do not override (only ``rename`` is).
|
|
29
|
+
|
|
30
|
+
Speed comes from two exact changes over APTED:
|
|
31
|
+
* the rename/Levenshtein cost matrix is computed **once** per node pair, and
|
|
32
|
+
* the DP runs in a tight array kernel that ``numba`` JIT-compiles to machine
|
|
33
|
+
code (``@njit(cache=True)``) when available.
|
|
34
|
+
|
|
35
|
+
``numba`` is optional. When it is missing (or fails to import) the pure-Python
|
|
36
|
+
Zhang-Shasha kernel is used instead — still exact, just without the JIT win.
|
|
37
|
+
Install ``numba`` for the fast path: ``pip install numba``.
|
|
38
|
+
|
|
39
|
+
Measured (``scripts/bench_table_metrics.py``, numba on, all three TEDS
|
|
40
|
+
variants, independent random tables — the worst case for edit distance)::
|
|
41
|
+
|
|
42
|
+
size APTED ms ZSS ms speedup max|Δ score|
|
|
43
|
+
8x5 89.6 13.2 6.8x 0.00e+00
|
|
44
|
+
15x8 843.3 115.6 7.3x 0.00e+00
|
|
45
|
+
25x10 3994.4 519.6 7.7x 0.00e+00
|
|
46
|
+
40x12 14895.4 1895.7 7.9x 0.00e+00
|
|
47
|
+
|
|
48
|
+
The win grows with table size (the metric is ``~O(cells^2)``) and the score
|
|
49
|
+
delta is exactly zero — the whole point: faster, not different.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import os
|
|
55
|
+
import sys
|
|
56
|
+
from typing import TYPE_CHECKING, Any
|
|
57
|
+
|
|
58
|
+
if TYPE_CHECKING:
|
|
59
|
+
from apted import Config
|
|
60
|
+
|
|
61
|
+
from parse_bench.evaluation.metrics.parse.teds_metric import TableTree
|
|
62
|
+
|
|
63
|
+
# Kill-switch: set PARSEBENCH_FAST_TEDS=0 to force the original APTED path.
|
|
64
|
+
# The default is ON because the result is exact by construction (see module
|
|
65
|
+
# docstring), with an automatic fallback to APTED on any unexpected error.
|
|
66
|
+
FAST_TEDS_ENABLED = os.environ.get("PARSEBENCH_FAST_TEDS", "1") != "0"
|
|
67
|
+
|
|
68
|
+
_LOGGED: set[str] = set()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _log_once(msg: str) -> None:
|
|
72
|
+
"""Emit ``msg`` at most once per process so it is obvious from the logs
|
|
73
|
+
which distance path (numba / pure-Python / APTED fallback) is active."""
|
|
74
|
+
if msg not in _LOGGED:
|
|
75
|
+
_LOGGED.add(msg)
|
|
76
|
+
print(f"[fast_tree_edit] {msg}", file=sys.stderr, flush=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
import numpy as _np
|
|
81
|
+
from numba import njit as _njit
|
|
82
|
+
|
|
83
|
+
_HAVE_NUMBA = True
|
|
84
|
+
_NUMBA_ERR: str | None = None
|
|
85
|
+
except Exception as _e: # pragma: no cover - depends on the environment
|
|
86
|
+
_HAVE_NUMBA = False
|
|
87
|
+
_NUMBA_ERR = repr(_e)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --------------------------------------------------------------------------- #
|
|
91
|
+
# Postorder indexing of a TableTree (the layout Zhang-Shasha needs).
|
|
92
|
+
# --------------------------------------------------------------------------- #
|
|
93
|
+
def _build_postorder(root: TableTree) -> dict[str, Any]:
|
|
94
|
+
"""Index ``root``'s nodes in postorder (1-based) with the two arrays
|
|
95
|
+
Zhang-Shasha needs: ``l[i]`` = the postorder id of node ``i``'s leftmost
|
|
96
|
+
leaf descendant, and ``keyroots`` = the nodes that start a distinct
|
|
97
|
+
leftmost-leaf path.
|
|
98
|
+
|
|
99
|
+
Returns a dict with ``n`` (node count), ``nodes`` (1-based list of the
|
|
100
|
+
original ``TableTree`` nodes, so the caller can compute rename costs),
|
|
101
|
+
``l`` and ``keyroots``.
|
|
102
|
+
"""
|
|
103
|
+
nodes: list[Any] = [None] # index 0 unused → 1-based postorder ids
|
|
104
|
+
leftmost: list[int] = [0]
|
|
105
|
+
|
|
106
|
+
# Iterative postorder to stay safe on pathologically deep trees.
|
|
107
|
+
# Stack entries: (node, child_index, first_child_leftmost).
|
|
108
|
+
stack: list[list[Any]] = [[root, 0, None]]
|
|
109
|
+
while stack:
|
|
110
|
+
frame = stack[-1]
|
|
111
|
+
node, child_idx, first_leftmost = frame
|
|
112
|
+
children = node.children
|
|
113
|
+
if child_idx < len(children):
|
|
114
|
+
frame[1] = child_idx + 1
|
|
115
|
+
stack.append([children[child_idx], 0, None])
|
|
116
|
+
continue
|
|
117
|
+
# All children processed → assign this node its postorder id.
|
|
118
|
+
stack.pop()
|
|
119
|
+
nodes.append(node)
|
|
120
|
+
idx = len(nodes) - 1
|
|
121
|
+
# A node's leftmost leaf is its first child's leftmost leaf; a leaf is
|
|
122
|
+
# its own leftmost leaf.
|
|
123
|
+
lm = first_leftmost if first_leftmost is not None else idx
|
|
124
|
+
leftmost.append(lm)
|
|
125
|
+
if stack:
|
|
126
|
+
parent = stack[-1]
|
|
127
|
+
if parent[2] is None: # record the leftmost id of the parent's 1st child
|
|
128
|
+
parent[2] = lm
|
|
129
|
+
|
|
130
|
+
n = len(nodes) - 1
|
|
131
|
+
# keyroots: for each distinct leftmost value keep the largest node id.
|
|
132
|
+
last_for_l: dict[int, int] = {}
|
|
133
|
+
for i in range(1, n + 1):
|
|
134
|
+
last_for_l[leftmost[i]] = i
|
|
135
|
+
keyroots = sorted(last_for_l.values())
|
|
136
|
+
return {"n": n, "nodes": nodes, "l": leftmost, "keyroots": keyroots}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _cost_matrix(a: dict[str, Any], b: dict[str, Any], config: Config) -> Any:
|
|
140
|
+
"""Substitution-cost matrix ``C[i][j] = config.rename(a_i, b_j)`` (1-based).
|
|
141
|
+
|
|
142
|
+
Each entry is computed exactly once here; APTED would recompute the same
|
|
143
|
+
rename several times inside its DP. Uses a numpy array when numba is
|
|
144
|
+
available (the kernel needs one), otherwise a list of lists.
|
|
145
|
+
"""
|
|
146
|
+
n1, n2 = a["n"], b["n"]
|
|
147
|
+
nodes_a, nodes_b = a["nodes"], b["nodes"]
|
|
148
|
+
c: Any # numpy array on the numba path, list-of-lists on the fallback path
|
|
149
|
+
if _HAVE_NUMBA:
|
|
150
|
+
c = _np.zeros((n1 + 1, n2 + 1), dtype=_np.float64)
|
|
151
|
+
else:
|
|
152
|
+
c = [[0.0] * (n2 + 1) for _ in range(n1 + 1)]
|
|
153
|
+
for i in range(1, n1 + 1):
|
|
154
|
+
node_a = nodes_a[i]
|
|
155
|
+
row = c[i]
|
|
156
|
+
for j in range(1, n2 + 1):
|
|
157
|
+
row[j] = config.rename(node_a, nodes_b[j])
|
|
158
|
+
return c
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --------------------------------------------------------------------------- #
|
|
162
|
+
# Zhang-Shasha DP — numba (compiled) and pure-Python (fallback). Both compute
|
|
163
|
+
# the exact ordered tree-edit distance with unit insert/delete cost.
|
|
164
|
+
# --------------------------------------------------------------------------- #
|
|
165
|
+
if _HAVE_NUMBA:
|
|
166
|
+
|
|
167
|
+
@_njit(cache=True, fastmath=False) # pragma: no cover - compiled to machine code
|
|
168
|
+
def _zss_dp_numba(n1, n2, l_a, l_b, kr_a, kr_b, c):
|
|
169
|
+
treedist = _np.zeros((n1 + 1, n2 + 1))
|
|
170
|
+
fd = _np.empty((n1 + 2, n2 + 2))
|
|
171
|
+
for ki in range(kr_a.shape[0]):
|
|
172
|
+
i = kr_a[ki]
|
|
173
|
+
li = l_a[i]
|
|
174
|
+
m = i - li + 1
|
|
175
|
+
for kj in range(kr_b.shape[0]):
|
|
176
|
+
j = kr_b[kj]
|
|
177
|
+
lj = l_b[j]
|
|
178
|
+
nn = j - lj + 1
|
|
179
|
+
fd[0, 0] = 0.0
|
|
180
|
+
for x in range(1, m + 1):
|
|
181
|
+
fd[x, 0] = fd[x - 1, 0] + 1.0
|
|
182
|
+
for y in range(1, nn + 1):
|
|
183
|
+
fd[0, y] = fd[0, y - 1] + 1.0
|
|
184
|
+
for x in range(1, m + 1):
|
|
185
|
+
di = li + x - 1
|
|
186
|
+
ldi = l_a[di]
|
|
187
|
+
for y in range(1, nn + 1):
|
|
188
|
+
dj = lj + y - 1
|
|
189
|
+
dele = fd[x - 1, y] + 1.0
|
|
190
|
+
ins = fd[x, y - 1] + 1.0
|
|
191
|
+
val = dele if dele < ins else ins
|
|
192
|
+
if ldi == li and l_b[dj] == lj:
|
|
193
|
+
sub = fd[x - 1, y - 1] + c[di, dj]
|
|
194
|
+
if sub < val:
|
|
195
|
+
val = sub
|
|
196
|
+
fd[x, y] = val
|
|
197
|
+
treedist[di, dj] = val
|
|
198
|
+
else:
|
|
199
|
+
sub = fd[ldi - li, l_b[dj] - lj] + treedist[di, dj]
|
|
200
|
+
if sub < val:
|
|
201
|
+
val = sub
|
|
202
|
+
fd[x, y] = val
|
|
203
|
+
return treedist[n1, n2]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _zss_dp_python(a: dict[str, Any], b: dict[str, Any], c: list[list[float]]) -> float:
|
|
207
|
+
n1, n2 = a["n"], b["n"]
|
|
208
|
+
l_a, l_b = a["l"], b["l"]
|
|
209
|
+
treedist = [[0.0] * (n2 + 1) for _ in range(n1 + 1)]
|
|
210
|
+
for i in a["keyroots"]:
|
|
211
|
+
li = l_a[i]
|
|
212
|
+
m = i - li + 1
|
|
213
|
+
for j in b["keyroots"]:
|
|
214
|
+
lj = l_b[j]
|
|
215
|
+
nn = j - lj + 1
|
|
216
|
+
fd = [[0.0] * (nn + 1) for _ in range(m + 1)]
|
|
217
|
+
for x in range(1, m + 1):
|
|
218
|
+
fd[x][0] = fd[x - 1][0] + 1.0
|
|
219
|
+
for y in range(1, nn + 1):
|
|
220
|
+
fd[0][y] = fd[0][y - 1] + 1.0
|
|
221
|
+
for x in range(1, m + 1):
|
|
222
|
+
di = li + x - 1
|
|
223
|
+
ldi = l_a[di]
|
|
224
|
+
for y in range(1, nn + 1):
|
|
225
|
+
dj = lj + y - 1
|
|
226
|
+
dele = fd[x - 1][y] + 1.0
|
|
227
|
+
ins = fd[x][y - 1] + 1.0
|
|
228
|
+
val = dele if dele < ins else ins
|
|
229
|
+
if ldi == li and l_b[dj] == lj:
|
|
230
|
+
sub = fd[x - 1][y - 1] + c[di][dj]
|
|
231
|
+
if sub < val:
|
|
232
|
+
val = sub
|
|
233
|
+
fd[x][y] = val
|
|
234
|
+
treedist[di][dj] = val
|
|
235
|
+
else:
|
|
236
|
+
sub = fd[ldi - li][l_b[dj] - lj] + treedist[di][dj]
|
|
237
|
+
if sub < val:
|
|
238
|
+
val = sub
|
|
239
|
+
fd[x][y] = val
|
|
240
|
+
return float(treedist[n1][n2])
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def tree_edit_distance(tree1: TableTree, tree2: TableTree, config: Config) -> float:
|
|
244
|
+
"""Exact ordered tree-edit distance between two ``TableTree`` forests.
|
|
245
|
+
|
|
246
|
+
Equivalent to ``APTED(tree1, tree2, config).compute_edit_distance()`` for
|
|
247
|
+
ParseBench's TEDS configs (which override only ``rename``; insert/delete
|
|
248
|
+
cost is APTED's default of 1). Uses the numba kernel when available and the
|
|
249
|
+
pure-Python kernel otherwise — both return the same value.
|
|
250
|
+
"""
|
|
251
|
+
a = _build_postorder(tree1)
|
|
252
|
+
b = _build_postorder(tree2)
|
|
253
|
+
c = _cost_matrix(a, b, config)
|
|
254
|
+
if _HAVE_NUMBA:
|
|
255
|
+
_log_once("active path: numba JIT Zhang-Shasha (fast)")
|
|
256
|
+
l_a = _np.array(a["l"], dtype=_np.int64)
|
|
257
|
+
l_b = _np.array(b["l"], dtype=_np.int64)
|
|
258
|
+
kr_a = _np.array(a["keyroots"], dtype=_np.int64)
|
|
259
|
+
kr_b = _np.array(b["keyroots"], dtype=_np.int64)
|
|
260
|
+
return float(_zss_dp_numba(a["n"], b["n"], l_a, l_b, kr_a, kr_b, c))
|
|
261
|
+
_log_once(f"FALLBACK: numba unavailable -> pure-Python Zhang-Shasha (exact, slower). cause={_NUMBA_ERR}")
|
|
262
|
+
return _zss_dp_python(a, b, c)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def warmup() -> bool:
|
|
266
|
+
"""Trigger numba JIT compilation once (cached on disk via ``cache=True``)
|
|
267
|
+
on a trivial 1x1 table, so the first real table isn't charged the compile.
|
|
268
|
+
Returns True if the fast (numba) path is active. Safe to call at startup;
|
|
269
|
+
swallows any error and reports the state instead of raising."""
|
|
270
|
+
try:
|
|
271
|
+
from apted import Config
|
|
272
|
+
|
|
273
|
+
from parse_bench.evaluation.metrics.parse.teds_metric import TableTree
|
|
274
|
+
|
|
275
|
+
cell = TableTree("td", 1, 1, ["a"])
|
|
276
|
+
row = TableTree("tr", None, None, None, cell)
|
|
277
|
+
table = TableTree("table", None, None, None, row)
|
|
278
|
+
tree_edit_distance(table, table, Config())
|
|
279
|
+
except Exception as e: # pragma: no cover - defensive
|
|
280
|
+
_log_once(f"FALLBACK: warmup failed -> {e!r}")
|
|
281
|
+
return False
|
|
282
|
+
return _HAVE_NUMBA
|