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.
Files changed (227) hide show
  1. parse_bench/__init__.py +3 -0
  2. parse_bench/analysis/__init__.py +6 -0
  3. parse_bench/analysis/aggregation_report.py +582 -0
  4. parse_bench/analysis/cli.py +472 -0
  5. parse_bench/analysis/comparison.py +382 -0
  6. parse_bench/analysis/comparison_core.py +357 -0
  7. parse_bench/analysis/comparison_report.py +2066 -0
  8. parse_bench/analysis/detailed_report.py +2254 -0
  9. parse_bench/analysis/leaderboard_report.py +852 -0
  10. parse_bench/analysis/metric_definitions.py +771 -0
  11. parse_bench/cli.py +267 -0
  12. parse_bench/data/__init__.py +1 -0
  13. parse_bench/data/cli.py +118 -0
  14. parse_bench/data/download.py +127 -0
  15. parse_bench/evaluation/__init__.py +11 -0
  16. parse_bench/evaluation/cli.py +435 -0
  17. parse_bench/evaluation/evaluators/__init__.py +17 -0
  18. parse_bench/evaluation/evaluators/base.py +34 -0
  19. parse_bench/evaluation/evaluators/extract.py +429 -0
  20. parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
  21. parse_bench/evaluation/evaluators/parse.py +1353 -0
  22. parse_bench/evaluation/evaluators/qa.py +199 -0
  23. parse_bench/evaluation/layout_adapters/__init__.py +21 -0
  24. parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
  25. parse_bench/evaluation/layout_adapters/base.py +105 -0
  26. parse_bench/evaluation/layout_adapters/registry.py +109 -0
  27. parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
  28. parse_bench/evaluation/layout_label_mappers/base.py +66 -0
  29. parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
  30. parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
  31. parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
  32. parse_bench/evaluation/metric_aggregation.py +56 -0
  33. parse_bench/evaluation/metrics/__init__.py +5 -0
  34. parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
  35. parse_bench/evaluation/metrics/attribution/constants.py +12 -0
  36. parse_bench/evaluation/metrics/attribution/core.py +1108 -0
  37. parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
  38. parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
  39. parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
  40. parse_bench/evaluation/metrics/base.py +33 -0
  41. parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
  42. parse_bench/evaluation/metrics/extract/__init__.py +29 -0
  43. parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
  44. parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
  45. parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
  46. parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
  47. parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
  48. parse_bench/evaluation/metrics/extract/test_types.py +11 -0
  49. parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
  50. parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
  51. parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
  52. parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
  53. parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
  54. parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
  55. parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
  56. parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
  57. parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
  58. parse_bench/evaluation/metrics/parse/__init__.py +5 -0
  59. parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
  60. parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
  61. parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
  62. parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
  63. parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
  64. parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
  65. parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
  66. parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
  67. parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
  68. parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
  69. parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
  70. parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
  71. parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
  72. parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
  73. parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
  74. parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
  75. parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
  76. parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
  77. parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
  78. parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
  79. parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
  80. parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
  81. parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
  82. parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
  83. parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
  84. parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
  85. parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
  86. parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
  87. parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
  88. parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
  89. parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
  90. parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
  91. parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
  92. parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
  93. parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
  94. parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
  95. parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
  96. parse_bench/evaluation/metrics/parse/test_types.py +103 -0
  97. parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
  98. parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
  99. parse_bench/evaluation/metrics/parse/utils.py +885 -0
  100. parse_bench/evaluation/metrics/qa/__init__.py +5 -0
  101. parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
  102. parse_bench/evaluation/qa/__init__.py +5 -0
  103. parse_bench/evaluation/qa/llm_service.py +335 -0
  104. parse_bench/evaluation/reports/__init__.py +8 -0
  105. parse_bench/evaluation/reports/csv.py +64 -0
  106. parse_bench/evaluation/reports/html.py +338 -0
  107. parse_bench/evaluation/reports/markdown.py +98 -0
  108. parse_bench/evaluation/reports/rule_csv.py +22 -0
  109. parse_bench/evaluation/runner.py +1864 -0
  110. parse_bench/evaluation/stats.py +104 -0
  111. parse_bench/extensions.py +72 -0
  112. parse_bench/inference/__init__.py +33 -0
  113. parse_bench/inference/chunkr_layout_extraction.py +160 -0
  114. parse_bench/inference/cli.py +484 -0
  115. parse_bench/inference/layout_extraction.py +422 -0
  116. parse_bench/inference/pipelines/__init__.py +59 -0
  117. parse_bench/inference/pipelines/extract.py +39 -0
  118. parse_bench/inference/pipelines/layout.py +142 -0
  119. parse_bench/inference/pipelines/parse.py +2603 -0
  120. parse_bench/inference/pipelines.py +0 -0
  121. parse_bench/inference/providers/__init__.py +28 -0
  122. parse_bench/inference/providers/base.py +196 -0
  123. parse_bench/inference/providers/cancellation.py +137 -0
  124. parse_bench/inference/providers/extract/__init__.py +22 -0
  125. parse_bench/inference/providers/extract/citations.py +549 -0
  126. parse_bench/inference/providers/extract/extend.py +851 -0
  127. parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
  128. parse_bench/inference/providers/layoutdet/__init__.py +25 -0
  129. parse_bench/inference/providers/layoutdet/adapters.py +946 -0
  130. parse_bench/inference/providers/layoutdet/base.py +203 -0
  131. parse_bench/inference/providers/layoutdet/chandra.py +449 -0
  132. parse_bench/inference/providers/layoutdet/docling.py +125 -0
  133. parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
  134. parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
  135. parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
  136. parse_bench/inference/providers/layoutdet/paddle.py +117 -0
  137. parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
  138. parse_bench/inference/providers/layoutdet/surya.py +250 -0
  139. parse_bench/inference/providers/layoutdet/yolo.py +109 -0
  140. parse_bench/inference/providers/parse/__init__.py +64 -0
  141. parse_bench/inference/providers/parse/_docling_common.py +233 -0
  142. parse_bench/inference/providers/parse/_layout_utils.py +611 -0
  143. parse_bench/inference/providers/parse/amazon_nova.py +515 -0
  144. parse_bench/inference/providers/parse/anthropic.py +882 -0
  145. parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
  146. parse_bench/inference/providers/parse/chandra2.py +633 -0
  147. parse_bench/inference/providers/parse/chunkr.py +268 -0
  148. parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
  149. parse_bench/inference/providers/parse/datalab.py +370 -0
  150. parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
  151. parse_bench/inference/providers/parse/docling.py +281 -0
  152. parse_bench/inference/providers/parse/docling_serve.py +289 -0
  153. parse_bench/inference/providers/parse/dots_ocr.py +574 -0
  154. parse_bench/inference/providers/parse/extend_parse.py +710 -0
  155. parse_bench/inference/providers/parse/falconocr.py +436 -0
  156. parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
  157. parse_bench/inference/providers/parse/gemma4.py +472 -0
  158. parse_bench/inference/providers/parse/glm_zai.py +229 -0
  159. parse_bench/inference/providers/parse/google.py +1125 -0
  160. parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
  161. parse_bench/inference/providers/parse/google_docai.py +776 -0
  162. parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
  163. parse_bench/inference/providers/parse/granite_vision.py +515 -0
  164. parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
  165. parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
  166. parse_bench/inference/providers/parse/landingai.py +452 -0
  167. parse_bench/inference/providers/parse/liteparse.py +350 -0
  168. parse_bench/inference/providers/parse/llamaparse.py +677 -0
  169. parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
  170. parse_bench/inference/providers/parse/markitdown.py +138 -0
  171. parse_bench/inference/providers/parse/mineru25.py +405 -0
  172. parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
  173. parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
  174. parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
  175. parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
  176. parse_bench/inference/providers/parse/oi_parser.py +222 -0
  177. parse_bench/inference/providers/parse/openai.py +740 -0
  178. parse_bench/inference/providers/parse/opendataloader.py +152 -0
  179. parse_bench/inference/providers/parse/paddleocr.py +624 -0
  180. parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
  181. parse_bench/inference/providers/parse/pulse.py +785 -0
  182. parse_bench/inference/providers/parse/pymupdf.py +207 -0
  183. parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
  184. parse_bench/inference/providers/parse/pypdf.py +179 -0
  185. parse_bench/inference/providers/parse/qwen.py +678 -0
  186. parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
  187. parse_bench/inference/providers/parse/reducto.py +546 -0
  188. parse_bench/inference/providers/parse/surya2.py +372 -0
  189. parse_bench/inference/providers/parse/tesseract.py +301 -0
  190. parse_bench/inference/providers/parse/textract.py +694 -0
  191. parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
  192. parse_bench/inference/providers/parse/unstructured.py +485 -0
  193. parse_bench/inference/providers/parse/warp_ingest.py +199 -0
  194. parse_bench/inference/providers/registry.py +49 -0
  195. parse_bench/inference/renormalize.py +170 -0
  196. parse_bench/inference/runner.py +2023 -0
  197. parse_bench/layout_label_mapping.py +424 -0
  198. parse_bench/layout_projection.py +179 -0
  199. parse_bench/pipeline/__init__.py +1 -0
  200. parse_bench/pipeline/cli.py +549 -0
  201. parse_bench/schemas/__init__.py +33 -0
  202. parse_bench/schemas/evaluation.py +93 -0
  203. parse_bench/schemas/extract_output.py +36 -0
  204. parse_bench/schemas/layout_detection_output.py +545 -0
  205. parse_bench/schemas/layout_ontology.py +315 -0
  206. parse_bench/schemas/metrics.py +69 -0
  207. parse_bench/schemas/parse_output.py +152 -0
  208. parse_bench/schemas/pipeline.py +22 -0
  209. parse_bench/schemas/pipeline_io.py +106 -0
  210. parse_bench/schemas/product.py +97 -0
  211. parse_bench/test_cases/__init__.py +25 -0
  212. parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
  213. parse_bench/test_cases/extract_field_paths.py +164 -0
  214. parse_bench/test_cases/layout_attribution_generation.py +287 -0
  215. parse_bench/test_cases/loader.py +652 -0
  216. parse_bench/test_cases/parse_rule_schemas.py +1071 -0
  217. parse_bench/test_cases/rule_filters.py +32 -0
  218. parse_bench/test_cases/rule_ids.py +107 -0
  219. parse_bench/test_cases/schema.py +427 -0
  220. parse_bench/utils/__init__.py +15 -0
  221. parse_bench/utils/gemini_layout_utils.py +670 -0
  222. parse_bench/utils/text_aggregation.py +100 -0
  223. parse_bench-1.0.0.dist-info/METADATA +476 -0
  224. parse_bench-1.0.0.dist-info/RECORD +227 -0
  225. parse_bench-1.0.0.dist-info/WHEEL +4 -0
  226. parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
  227. 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