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,600 @@
1
+ """TEDS (Tree Edit Distance based Similarity) metric for HTML table comparison.
2
+
3
+ This metric computes structural and content similarity between HTML tables
4
+ using the TEDS metric from the PubTabNet paper.
5
+
6
+ Three variants are supported:
7
+ - teds: Full content comparison (structure + Levenshtein on cell text)
8
+ - teds_struct: Structure-only (ignores cell text entirely)
9
+ - teds_struct_bool: Structure + boolean content awareness (penalizes when one
10
+ cell is empty and the other is not, but ignores the actual text)
11
+
12
+ Reference:
13
+ https://github.com/ibm-aur-nlp/PubTabNet/blob/master/src/metric.py
14
+ """
15
+
16
+ from collections import deque
17
+ from typing import Any
18
+
19
+ import Levenshtein
20
+ import numpy as np
21
+ from apted import APTED, Config
22
+ from apted.helpers import Tree
23
+ from lxml import etree, html
24
+
25
+ from parse_bench.evaluation.metrics.base import Metric
26
+ from parse_bench.evaluation.metrics.parse import fast_tree_edit
27
+ from parse_bench.evaluation.metrics.parse.table_pairing import assign_tables, page_blocking_active
28
+ from parse_bench.evaluation.metrics.parse.utils import normalize_cell_text
29
+ from parse_bench.schemas.evaluation import MetricValue
30
+
31
+ # =============================================================================
32
+ # Variant names
33
+ # =============================================================================
34
+
35
+ TEDS_CONTENT = "teds"
36
+ TEDS_STRUCT = "teds_struct"
37
+ TEDS_STRUCT_BOOL = "teds_struct_bool"
38
+ ALL_TEDS_VARIANTS = frozenset({TEDS_CONTENT, TEDS_STRUCT, TEDS_STRUCT_BOOL})
39
+
40
+ # =============================================================================
41
+ # TEDS Implementation (adapted from PubTabNet)
42
+ # =============================================================================
43
+
44
+
45
+ class TableTree(Tree):
46
+ """
47
+ Custom tree node for HTML table elements.
48
+
49
+ Stores tag name, colspan/rowspan for cells, and tokenized content.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ tag: str,
55
+ colspan: int | None = None,
56
+ rowspan: int | None = None,
57
+ content: list[str] | None = None,
58
+ *children: "TableTree",
59
+ ):
60
+ self.tag = tag
61
+ self.colspan = colspan
62
+ self.rowspan = rowspan
63
+ self.content = content
64
+ self.children = list(children)
65
+
66
+ def bracket(self) -> str:
67
+ """Show tree using brackets notation (for debugging)."""
68
+ if self.tag == "td":
69
+ result = f'"tag": {self.tag}, "colspan": {self.colspan}, "rowspan": {self.rowspan}, "text": {self.content}'
70
+ else:
71
+ result = f'"tag": {self.tag}'
72
+ for child in self.children:
73
+ result += child.bracket()
74
+ return "{" + result + "}"
75
+
76
+
77
+ class ContentConfig(Config):
78
+ """
79
+ APTED configuration for full TEDS (structure + content).
80
+
81
+ Compares HTML table nodes by:
82
+ - Tag name (must match exactly)
83
+ - Colspan/rowspan attributes (must match exactly)
84
+ - Cell text content (compared using normalized Levenshtein distance)
85
+ """
86
+
87
+ @staticmethod
88
+ def maximum(*sequences: str) -> int:
89
+ """Get maximum possible value for normalization."""
90
+ return max(map(len, sequences))
91
+
92
+ def normalized_distance(self, *sequences: str) -> float:
93
+ """Get Levenshtein distance normalized to 0-1 range."""
94
+ if not sequences[0] and not sequences[1]:
95
+ return 0.0
96
+ max_len = self.maximum(*sequences)
97
+ if max_len == 0:
98
+ return 0.0
99
+ return float(Levenshtein.distance(sequences[0], sequences[1])) / max_len
100
+
101
+ def rename(self, node1: TableTree, node2: TableTree) -> float:
102
+ """
103
+ Compute the cost of renaming node1 to node2.
104
+
105
+ Returns:
106
+ 0.0 if nodes are identical
107
+ 1.0 if tags or spans differ
108
+ Normalized Levenshtein distance for cell content
109
+ """
110
+ # Tags must match
111
+ if node1.tag != node2.tag:
112
+ return 1.0
113
+
114
+ # For cells, colspan and rowspan must match
115
+ if node1.colspan != node2.colspan or node1.rowspan != node2.rowspan:
116
+ return 1.0
117
+
118
+ # For td cells, compare content
119
+ if node1.tag == "td":
120
+ if node1.content or node2.content:
121
+ s1 = normalize_cell_text("".join(node1.content) if node1.content else "")
122
+ s2 = normalize_cell_text("".join(node2.content) if node2.content else "")
123
+ return self.normalized_distance(s1, s2)
124
+
125
+ return 0.0
126
+
127
+
128
+ class StructConfig(Config):
129
+ """
130
+ APTED configuration for TEDS-Struct (structure only).
131
+
132
+ Compares HTML table nodes by structure alone:
133
+ - Tag name (must match exactly)
134
+ - Colspan/rowspan attributes (must match exactly)
135
+ - Cell text content is ignored entirely
136
+ """
137
+
138
+ def rename(self, node1: TableTree, node2: TableTree) -> float:
139
+ """
140
+ Compute the cost of renaming node1 to node2 (structure only).
141
+
142
+ Returns:
143
+ 0.0 if tag and spans match
144
+ 1.0 if tags or spans differ
145
+ """
146
+ if node1.tag != node2.tag:
147
+ return 1.0
148
+ if node1.colspan != node2.colspan or node1.rowspan != node2.rowspan:
149
+ return 1.0
150
+ return 0.0
151
+
152
+
153
+ class StructBooleanContentConfig(Config):
154
+ """
155
+ APTED configuration for TEDS-Struct with boolean content awareness.
156
+
157
+ Like StructConfig, but additionally penalizes mismatches in cell
158
+ emptiness: cost is 1.0 if one cell is empty and the other is not,
159
+ 0.0 if both are empty or both are non-empty. The actual text content
160
+ is ignored — only its presence or absence matters.
161
+ """
162
+
163
+ def rename(self, node1: TableTree, node2: TableTree) -> float:
164
+ """
165
+ Compute the cost of renaming node1 to node2 (structure + boolean content).
166
+
167
+ Returns:
168
+ 0.0 if tag/spans match and both cells are empty or both non-empty
169
+ 1.0 if tags or spans differ, or one cell is empty and the other isn't
170
+ """
171
+ if node1.tag != node2.tag:
172
+ return 1.0
173
+ if node1.colspan != node2.colspan or node1.rowspan != node2.rowspan:
174
+ return 1.0
175
+ if node1.tag == "td":
176
+ s1 = normalize_cell_text("".join(node1.content) if node1.content else "")
177
+ s2 = normalize_cell_text("".join(node2.content) if node2.content else "")
178
+ if bool(s1) != bool(s2):
179
+ return 1.0
180
+ return 0.0
181
+
182
+
183
+ # Map variant names to their Config classes
184
+ VARIANT_CONFIGS: dict[str, type[Config]] = {
185
+ TEDS_CONTENT: ContentConfig,
186
+ TEDS_STRUCT: StructConfig,
187
+ TEDS_STRUCT_BOOL: StructBooleanContentConfig,
188
+ }
189
+
190
+
191
+ def _edit_distance(tree_pred: "TableTree", tree_true: "TableTree", config: Config) -> float:
192
+ """Ordered tree-edit distance between two table trees.
193
+
194
+ Prefers the fast Zhang-Shasha implementation in ``fast_tree_edit`` (numba
195
+ JIT when available, pure-Python otherwise). Zhang-Shasha computes the
196
+ *same* optimal ordered tree-edit distance as APTED, so the TEDS score is
197
+ unchanged — this is a speedup, not a re-definition of the metric. Any
198
+ unexpected failure (or ``PARSEBENCH_FAST_TEDS=0``) falls back to APTED so
199
+ the metric never breaks on an edge case the fast path hasn't seen.
200
+ """
201
+ if fast_tree_edit.FAST_TEDS_ENABLED:
202
+ try:
203
+ return fast_tree_edit.tree_edit_distance(tree_pred, tree_true, config)
204
+ except Exception as e: # pragma: no cover - defensive: never fail scoring
205
+ fast_tree_edit._log_once(f"FALLBACK: fast tree-edit failed -> APTED. cause={e!r}")
206
+ return float(APTED(tree_pred, tree_true, config).compute_edit_distance())
207
+
208
+
209
+ class TEDS:
210
+ """
211
+ Tree Edit Distance based Similarity metric for HTML tables.
212
+
213
+ Computes similarity between two HTML tables using tree edit distance.
214
+ Supports multiple scoring variants in a single call by sharing the
215
+ HTML parsing and tree construction work.
216
+ """
217
+
218
+ def __init__(
219
+ self,
220
+ ignore_nodes: list[str] | None = None,
221
+ variants: set[str] | None = None,
222
+ ):
223
+ self.ignore_nodes = ignore_nodes
224
+ self.variants = variants if variants is not None else ALL_TEDS_VARIANTS
225
+ self._tokens: list[str] = []
226
+
227
+ def _tokenize(self, node: Any) -> None:
228
+ """Tokenize table cell content into a list of tokens."""
229
+ self._tokens.append(f"<{node.tag}>")
230
+ if node.text is not None:
231
+ self._tokens.extend(list(node.text))
232
+ for child in node.getchildren():
233
+ self._tokenize(child)
234
+ if node.tag != "unk":
235
+ self._tokens.append(f"</{node.tag}>")
236
+ if node.tag != "td" and node.tail is not None:
237
+ self._tokens.extend(list(node.tail))
238
+
239
+ def _load_html_tree(self, node: Any, parent: TableTree | None = None) -> TableTree | None:
240
+ """
241
+ Convert an lxml HTML element to a TableTree for APTED.
242
+
243
+ Args:
244
+ node: lxml element node
245
+ parent: Parent TableTree node (for recursive building)
246
+
247
+ Returns:
248
+ Root TableTree node if parent is None, else None
249
+ """
250
+ if node.tag == "td" or node.tag == "th":
251
+ # For cells, extract content tokens
252
+ self._tokens = []
253
+ self._tokenize(node)
254
+ cell_content = self._tokens[1:-1].copy()
255
+
256
+ new_node = TableTree(
257
+ node.tag,
258
+ int(node.attrib.get("colspan", "1")),
259
+ int(node.attrib.get("rowspan", "1")),
260
+ cell_content,
261
+ *deque(),
262
+ )
263
+ else:
264
+ new_node = TableTree(node.tag, None, None, None, *deque())
265
+
266
+ if parent is not None:
267
+ parent.children.append(new_node)
268
+
269
+ # Recursively process children (but not for cells - their content is tokenized)
270
+ if node.tag not in ("td", "th"):
271
+ for child in node.getchildren():
272
+ self._load_html_tree(child, new_node)
273
+
274
+ if parent is None:
275
+ return new_node
276
+ return None
277
+
278
+ def evaluate(self, pred: str, true: str) -> tuple[dict[str, float], int, int]:
279
+ """
280
+ Compute TEDS scores between predicted and ground truth HTML tables.
281
+
282
+ Parses HTML and builds trees once, then runs APTED for each requested
283
+ variant. APTED does not mutate the TableTree nodes, so the same trees
284
+ are safely reused across calls.
285
+
286
+ Args:
287
+ pred: Predicted HTML table string
288
+ true: Ground truth HTML table string
289
+
290
+ Returns:
291
+ Tuple of (scores_dict, gt_nodes, pred_nodes) where scores_dict
292
+ maps variant name to its TEDS score.
293
+ """
294
+ empty_scores = dict.fromkeys(self.variants, 0.0)
295
+
296
+ if not pred or not true:
297
+ return (empty_scores, 0, 0)
298
+
299
+ try:
300
+ parser = html.HTMLParser(remove_comments=True, encoding="utf-8")
301
+ pred_doc = html.fromstring(pred, parser=parser)
302
+ true_doc = html.fromstring(true, parser=parser)
303
+ except Exception:
304
+ return (empty_scores, 0, 0)
305
+
306
+ # Find table elements
307
+ if pred_doc.tag == "table":
308
+ pred_table = pred_doc
309
+ else:
310
+ pred_tables = pred_doc.xpath(".//table")
311
+ if not pred_tables:
312
+ return (empty_scores, 0, 0)
313
+ pred_table = pred_tables[0]
314
+
315
+ if true_doc.tag == "table":
316
+ true_table = true_doc
317
+ else:
318
+ true_tables = true_doc.xpath(".//table")
319
+ if not true_tables:
320
+ return (empty_scores, 0, 0)
321
+ true_table = true_tables[0]
322
+
323
+ # Optionally strip certain nodes
324
+ if self.ignore_nodes:
325
+ etree.strip_tags(pred_table, *self.ignore_nodes)
326
+ etree.strip_tags(true_table, *self.ignore_nodes)
327
+
328
+ # Count nodes for normalization
329
+ n_nodes_pred = len(pred_table.xpath(".//*")) + 1 # +1 for root
330
+ n_nodes_true = len(true_table.xpath(".//*")) + 1
331
+ n_nodes = max(n_nodes_pred, n_nodes_true)
332
+
333
+ if n_nodes == 0:
334
+ return (empty_scores, n_nodes_true, n_nodes_pred)
335
+
336
+ # Compute edit distance for each variant.
337
+ # We rebuild the TableTree for each variant since it's cheap (O(n))
338
+ # and avoids any potential issues with APTED's internal indexing.
339
+ scores: dict[str, float] = {}
340
+ for variant in self.variants:
341
+ tree_pred = self._load_html_tree(pred_table)
342
+ tree_true = self._load_html_tree(true_table)
343
+ # n_nodes > 0 (checked above) guarantees both trees are non-None.
344
+ assert tree_pred is not None and tree_true is not None
345
+ config = VARIANT_CONFIGS[variant]()
346
+ distance = _edit_distance(tree_pred, tree_true, config)
347
+ scores[variant] = max(0.0, 1.0 - (float(distance) / n_nodes))
348
+
349
+ return (scores, n_nodes_true, n_nodes_pred)
350
+
351
+
352
+ def extract_html_tables(content: str) -> list[str]:
353
+ """
354
+ Extract all HTML tables from markdown content.
355
+
356
+ Args:
357
+ content: Markdown content potentially containing HTML tables
358
+
359
+ Returns:
360
+ List of HTML table strings
361
+ """
362
+ if not content:
363
+ return []
364
+
365
+ try:
366
+ parser = html.HTMLParser(remove_comments=True, encoding="utf-8")
367
+ doc = html.fromstring(content, parser=parser)
368
+ except Exception:
369
+ return []
370
+
371
+ tables = []
372
+
373
+ # If the root is a table, include it
374
+ if doc.tag == "table":
375
+ tables.append(etree.tostring(doc, encoding="unicode"))
376
+ else:
377
+ # Find all nested tables
378
+ for table in doc.xpath(".//table"):
379
+ tables.append(etree.tostring(table, encoding="unicode"))
380
+
381
+ return tables
382
+
383
+
384
+ # =============================================================================
385
+ # TEDSMetric class
386
+ # =============================================================================
387
+
388
+
389
+ class TEDSMetric(Metric):
390
+ """
391
+ TEDS metric for comparing HTML tables in markdown content.
392
+
393
+ Computes Tree Edit Distance based Similarity between expected and actual
394
+ HTML tables. Auto-detects tables in both expected and actual markdown.
395
+
396
+ Supports multiple TEDS variants (content, struct, struct+empty) and
397
+ returns one MetricValue per variant.
398
+ """
399
+
400
+ def __init__(self, variants: set[str] | None = None):
401
+ """
402
+ Initialize TEDSMetric.
403
+
404
+ Args:
405
+ variants: Set of variant names to compute. Defaults to all variants.
406
+ Valid names: "teds", "teds_struct", "teds_struct_bool".
407
+ """
408
+ self.variants = variants if variants is not None else set(ALL_TEDS_VARIANTS)
409
+
410
+ @property
411
+ def name(self) -> str:
412
+ """Return the name of this metric."""
413
+ return "teds"
414
+
415
+ def compute( # type: ignore[override]
416
+ self,
417
+ expected: str,
418
+ actual: str,
419
+ *,
420
+ expected_pages: list[int] | None = None,
421
+ actual_pages: list[int] | None = None,
422
+ **kwargs: Any,
423
+ ) -> list[MetricValue]:
424
+ """
425
+ Compute TEDS scores between expected and actual markdown content.
426
+
427
+ Uses Hungarian algorithm for optimal table matching (based on
428
+ TEDS-Content scores when available, otherwise the first variant).
429
+ Then applies the same matching to all requested variants.
430
+
431
+ Args:
432
+ expected: Expected markdown with HTML tables (ground truth)
433
+ actual: Actual markdown with HTML tables (from inference)
434
+ expected_pages: Optional page number (1-indexed) per GT table, aligned
435
+ with the tables extracted from ``expected``. When supplied with
436
+ ``actual_pages`` (and length-consistent), table matching is solved
437
+ per page so a GT table only pairs with a prediction on the same
438
+ page. Omit for the document-global assignment.
439
+ actual_pages: Optional page number per predicted table, aligned with
440
+ the tables extracted from ``actual``.
441
+ kwargs: Additional parameters (not used)
442
+
443
+ Returns:
444
+ List of MetricValues, one per requested variant.
445
+ """
446
+ # Extract tables from both
447
+ expected_tables = extract_html_tables(expected)
448
+ actual_tables = extract_html_tables(actual)
449
+
450
+ # No tables in expected means we can't compute TEDS
451
+ if not expected_tables:
452
+ return [
453
+ MetricValue(
454
+ metric_name=variant,
455
+ value=0.0,
456
+ metadata={
457
+ "note": "No tables found in expected markdown",
458
+ "tables_found_expected": 0,
459
+ "tables_found_actual": len(actual_tables),
460
+ },
461
+ )
462
+ for variant in sorted(self.variants)
463
+ ]
464
+
465
+ # No tables in actual means all tables are missing
466
+ if not actual_tables:
467
+ return [
468
+ MetricValue(
469
+ metric_name=variant,
470
+ value=0.0,
471
+ metadata={
472
+ "note": "No tables found in actual markdown",
473
+ "tables_found_expected": len(expected_tables),
474
+ "tables_found_actual": 0,
475
+ "tables_matched": 0,
476
+ },
477
+ )
478
+ for variant in sorted(self.variants)
479
+ ]
480
+
481
+ teds_calculator = TEDS(variants=self.variants)
482
+ n_expected = len(expected_tables)
483
+ n_actual = len(actual_tables)
484
+
485
+ # When per-table page labels are supplied, only same-page pairs are
486
+ # eligible to match — so only those need a (costly) TEDS comparison.
487
+ blocked = page_blocking_active(expected_pages, actual_pages, n_expected, n_actual)
488
+ allowed_pairs = [
489
+ (i, j)
490
+ for i in range(n_expected)
491
+ for j in range(n_actual)
492
+ if not blocked or expected_pages[i] == actual_pages[j] # type: ignore[index]
493
+ ]
494
+ total_pairs = len(allowed_pairs)
495
+
496
+ print(
497
+ f" TEDS: comparing {n_expected} expected x {n_actual} actual = {total_pairs} table pair(s)"
498
+ f"{' (same-page only)' if blocked else ''}",
499
+ flush=True,
500
+ )
501
+
502
+ # Build cost matrix (negative TEDS scores for minimization)
503
+ # Rows: expected tables, Columns: actual tables
504
+ # Also store full results to avoid recomputation later. Disallowed
505
+ # (cross-page) cells keep cost 0.0 and are never read.
506
+ cost_matrix = np.zeros((n_expected, n_actual))
507
+ results_cache: dict[tuple[int, int], tuple[dict[str, float], int, int]] = {}
508
+
509
+ # Use TEDS-Content for matching if available, else first variant
510
+ matching_variant = TEDS_CONTENT if TEDS_CONTENT in self.variants else next(iter(sorted(self.variants)))
511
+
512
+ for pair_idx, (i, j) in enumerate(allowed_pairs, start=1):
513
+ if total_pairs > 1:
514
+ print(f" TEDS: table pair {pair_idx}/{total_pairs}", flush=True)
515
+ scores, gt_nodes, pred_nodes = teds_calculator.evaluate(actual_tables[j], expected_tables[i])
516
+ results_cache[(i, j)] = (scores, gt_nodes, pred_nodes)
517
+ cost_matrix[i, j] = -scores[matching_variant]
518
+
519
+ # Solve assignment (per page when page labels are supplied, else global).
520
+ row_ind, col_ind = assign_tables(cost_matrix, expected_pages, actual_pages)
521
+
522
+ # Build one MetricValue per variant
523
+ metric_values: list[MetricValue] = []
524
+ for variant in sorted(self.variants):
525
+ per_table_scores: list[float] = []
526
+ per_table_details: list[dict[str, Any]] = []
527
+ matched_gt_indices: set[int] = set()
528
+
529
+ for gt_idx, pred_idx in zip(row_ind, col_ind, strict=True):
530
+ gt_idx_int = int(gt_idx)
531
+ pred_idx_int = int(pred_idx)
532
+ scores, gt_nodes, pred_nodes = results_cache[(gt_idx_int, pred_idx_int)]
533
+ score = scores[variant]
534
+ per_table_scores.append(score)
535
+ detail: dict[str, Any] = {
536
+ "gt_table_index": gt_idx_int,
537
+ "pred_table_index": pred_idx_int,
538
+ "score": score,
539
+ "gt_nodes": gt_nodes,
540
+ "pred_nodes": pred_nodes,
541
+ }
542
+ if blocked:
543
+ detail["gt_page"] = expected_pages[gt_idx_int] # type: ignore[index]
544
+ detail["pred_page"] = actual_pages[pred_idx_int] # type: ignore[index]
545
+ per_table_details.append(detail)
546
+ matched_gt_indices.add(gt_idx_int)
547
+
548
+ # If there are unmatched expected tables, count them as 0
549
+ for i in range(n_expected):
550
+ if i not in matched_gt_indices:
551
+ per_table_scores.append(0.0)
552
+ per_table_details.append(
553
+ {
554
+ "gt_table_index": i,
555
+ "pred_table_index": None,
556
+ "score": 0.0,
557
+ "gt_nodes": 0,
558
+ "pred_nodes": 0,
559
+ "note": "No matching table in actual",
560
+ }
561
+ )
562
+
563
+ aggregate_score = sum(per_table_scores) / len(per_table_scores) if per_table_scores else 0.0
564
+
565
+ # Build human-readable detail strings
566
+ details: list[str] = []
567
+ details.append(f"{n_expected} table(s) expected, {n_actual} found, {len(row_ind)} matched")
568
+ for td in per_table_details:
569
+ gi = td["gt_table_index"]
570
+ pi = td.get("pred_table_index")
571
+ if pi is None:
572
+ details.append(f"Table {gi + 1}: no match found in prediction")
573
+ else:
574
+ details.append(
575
+ f"Table {gi + 1}: {variant}={td['score']:.3f}"
576
+ f" (gt_nodes={td['gt_nodes']}, pred_nodes={td['pred_nodes']})"
577
+ )
578
+
579
+ metric_values.append(
580
+ MetricValue(
581
+ metric_name=variant,
582
+ value=aggregate_score,
583
+ metadata={
584
+ "tables_predicted": True,
585
+ "tables_found_expected": n_expected,
586
+ "tables_found_actual": n_actual,
587
+ "tables_matched": len(row_ind),
588
+ "per_table_scores": per_table_scores,
589
+ "per_table_details": per_table_details,
590
+ },
591
+ details=details,
592
+ )
593
+ )
594
+
595
+ variant_str = ", ".join(
596
+ f"{v}={m.value:.4f}" for v, m in zip(sorted(self.variants), metric_values, strict=False)
597
+ )
598
+ print(f" TEDS: done, {variant_str}", flush=True)
599
+
600
+ return metric_values