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,1453 @@
1
+ """Table Record Match Metric.
2
+
3
+ Evaluates semantic equivalence of tables by treating them as collections
4
+ of key-value record objects. Each HTML table is converted to a list of
5
+ dicts, then predicted records are matched to GT records via the Hungarian
6
+ algorithm with graduated cell-level scoring.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+ from rapidfuzz import fuzz
17
+ from scipy.optimize import linear_sum_assignment
18
+
19
+ from parse_bench.evaluation.metrics.parse.table_extraction import (
20
+ ExtractedTable,
21
+ extract_html_tables,
22
+ )
23
+ from parse_bench.evaluation.metrics.parse.table_parsing import (
24
+ TableData,
25
+ parse_html_tables,
26
+ )
27
+ from parse_bench.evaluation.metrics.parse.table_title_stripping import (
28
+ HeaderHints,
29
+ HeaderInfo,
30
+ extract_header_info,
31
+ strip_title_rows,
32
+ )
33
+ from parse_bench.evaluation.metrics.parse.utils import (
34
+ _normalize_sub_sup_for_table as _shared_normalize_sub_sup,
35
+ )
36
+ from parse_bench.evaluation.metrics.parse.utils import (
37
+ _normalize_table_boolean_marker,
38
+ cells_match_leader_insensitive,
39
+ normalize_text,
40
+ strip_dot_leaders,
41
+ )
42
+ from parse_bench.schemas.evaluation import MetricValue
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # ===========================================================================
47
+ # Phase 1: Normalization
48
+ # ===========================================================================
49
+
50
+ SYNTHETIC_KEY_PREFIX = "col_"
51
+ KEY_SEPARATOR = " "
52
+
53
+ # Minimum header similarity to consider a column pair matched
54
+ _COLUMN_MATCH_THRESHOLD = 0.9
55
+
56
+ _EMPTY_SENTINELS = frozenset({"", "nan", "NaN", "NAN", "none", "None", "-", "—", "n/a", "N/A"})
57
+
58
+ # Sup/sub conversion is now defined once in utils.py and shared between
59
+ # GriTS normalize_cell_text and TRM normalize_table. The local name is
60
+ # preserved as a thin alias so the existing import path
61
+ # (`from ...table_record_match_metric import _normalize_sub_sup_for_table`)
62
+ # in tests and other modules continues to work.
63
+ _normalize_sub_sup_for_table = _shared_normalize_sub_sup
64
+
65
+
66
+ def _is_all_empty_record(record: dict[str, str]) -> bool:
67
+ """Check if every value in a record is an empty string."""
68
+ return all(v.strip() == "" for v in record.values())
69
+
70
+
71
+ def _is_empty_or_nan(value: str) -> bool:
72
+ """Check if a value represents an empty/missing cell."""
73
+ return str(value).strip() in _EMPTY_SENTINELS
74
+
75
+
76
+ def _normalize_trm_cell_text(text: str) -> str:
77
+ """Extra cell-level normalization for table record match scoring.
78
+
79
+ Applied only inside cell_score() — NOT in the shared normalize_text()
80
+ which is used by TEDS, GriTS, header accuracy, and text similarity.
81
+
82
+ Rules:
83
+ 1. Strip leader dots ("........" or ". . . .") — visual fillers whose
84
+ exact count is meaningless and varies between GT and prediction.
85
+ 2. Strip whitespace adjacent to $, %, (, )
86
+ - "$ 5,061" → "$5,061"
87
+ - "50 %" → "50%"
88
+ - "word (n)" → "word(n)"
89
+ 3. Strip commas from numeric contexts (dollars, percentages, plain numbers)
90
+ - "$5,061" → "$5061"
91
+ - "1,000%" → "1000%"
92
+ - "1,000,000" → "1000000"
93
+ - But NOT "Smith, John" (commas between non-digit chars are preserved)
94
+ 4. Normalize whole-cell boolean markers ("✓", "✗", "●", "[yes]",
95
+ "[no]") to yes/no.
96
+ """
97
+ marker = _normalize_table_boolean_marker(text)
98
+ if marker != text:
99
+ return marker
100
+
101
+ # --- leader dots (e.g. "Item ......... 5" or "Item . . . . 5") ---
102
+ # These are visual fillers whose exact count is meaningless; strip them
103
+ # so ground-truth and prediction don't diverge on dot count. The run
104
+ # definition lives in ``utils.strip_dot_leaders`` and is shared with
105
+ # ``normalize_cell_text`` so GriTS and TRM cannot drift apart on it.
106
+ text = strip_dot_leaders(text)
107
+ text = re.sub(r" +", " ", text).strip() # collapse leftover whitespace
108
+
109
+ # --- whitespace around punctuation ---
110
+ text = re.sub(r"\$\s+", "$", text)
111
+ text = re.sub(r"\s+%", "%", text)
112
+ text = re.sub(r"\s+\(", "(", text)
113
+ text = re.sub(r"\s+\)", ")", text)
114
+ # Strip whitespace before the registered-trademark symbol so "Apple ® Inc"
115
+ # and "Apple® Inc" compare equal.
116
+ text = re.sub(r"\s+®", "®", text)
117
+
118
+ # --- comma removal in numeric contexts ---
119
+ # Remove commas that sit between digits (thousands separators).
120
+ # This covers "$1,000", "1,000%", "(1,000)", and plain "1,000,000"
121
+ # but leaves "Smith, John" untouched because the comma is not
122
+ # between two digits.
123
+ text = re.sub(r"(\d),(\d)", r"\1\2", text)
124
+ return text
125
+
126
+
127
+ def _normalize_trm_table_text(text: str) -> str:
128
+ """Normalize one TRM table cell/header while preserving marker order."""
129
+ text = _normalize_sub_sup_for_table(text)
130
+ text = _normalize_table_boolean_marker(text)
131
+ text = normalize_text(text)
132
+ return _normalize_trm_cell_text(text)
133
+
134
+
135
+ def normalize_table(table: TableData) -> TableData:
136
+ """Normalize all cell text in a table for comparison.
137
+
138
+ Applies normalize_text() + _normalize_trm_cell_text() to every cell in
139
+ table.data and to col_headers text, producing a new TableData with
140
+ normalized values throughout. Columns whose data cells AND header
141
+ entries are all empty strings after normalization are dropped, and
142
+ remaining column indices are renumbered to stay contiguous.
143
+ """
144
+ if table.data.size == 0:
145
+ return table
146
+ n_rows, n_cols = table.data.shape
147
+ normalized = np.empty_like(table.data)
148
+ for r in range(n_rows):
149
+ for c in range(n_cols):
150
+ normalized[r, c] = _normalize_trm_table_text(str(table.data[r, c]))
151
+ # Normalize col_headers and row_headers metadata to match data cell normalization
152
+ normalized_col_headers: dict[int, list[tuple[int, str]]] = {}
153
+ for col_idx, entries in table.col_headers.items():
154
+ normalized_col_headers[col_idx] = [(row_idx, _normalize_trm_table_text(text)) for row_idx, text in entries]
155
+ normalized_row_headers: dict[int, list[tuple[int, str]]] = {}
156
+ for row_idx, entries in table.row_headers.items():
157
+ normalized_row_headers[row_idx] = [(col_idx, _normalize_trm_table_text(text)) for col_idx, text in entries]
158
+
159
+ # Drop columns whose data cells AND header entries are all literally
160
+ # empty strings after normalization. Such columns add noise to header
161
+ # alignment and inflate the union denominator used by _record_similarity
162
+ # without contributing any signal. Sentinel values like "-" or "n/a"
163
+ # count as real content and are NOT dropped.
164
+ #
165
+ # This runs independently on GT and pred, so the two normalized tables
166
+ # may end up with *different* column counts when (say) GT has an empty
167
+ # column 2 but pred has real content there. That's intentional: record
168
+ # matching is content-driven — ``_record_similarity`` compares columns
169
+ # via the ``column_mapping`` keyed by header text, not by positional
170
+ # index, so differing shapes are fine and the unmapped column on the
171
+ # longer side penalizes the score via ``max(n_gt_cols, n_pred_cols)``.
172
+ def _col_is_empty(c: int) -> bool:
173
+ for r in range(n_rows):
174
+ if str(normalized[r, c]) != "":
175
+ return False
176
+ for _row_idx, text in normalized_col_headers.get(c, []):
177
+ if text != "":
178
+ return False
179
+ return True
180
+
181
+ keep_cols = [c for c in range(n_cols) if not _col_is_empty(c)]
182
+ if len(keep_cols) != n_cols:
183
+ old_to_new = {old: new for new, old in enumerate(keep_cols)}
184
+ normalized = normalized[:, keep_cols]
185
+ normalized_col_headers = {
186
+ old_to_new[c]: normalized_col_headers[c] for c in keep_cols if c in normalized_col_headers
187
+ }
188
+ header_cols = {old_to_new[c] for c in table.header_cols if c in old_to_new}
189
+ header_cells = {(r, old_to_new[c]) for (r, c) in table.header_cells if c in old_to_new}
190
+ normalized_row_headers = {
191
+ row_idx: [(old_to_new[c], text) for c, text in entries if c in old_to_new]
192
+ for row_idx, entries in normalized_row_headers.items()
193
+ }
194
+ else:
195
+ header_cols = table.header_cols
196
+ header_cells = table.header_cells
197
+
198
+ return TableData(
199
+ data=normalized,
200
+ header_rows=table.header_rows,
201
+ header_cols=header_cols,
202
+ col_headers=normalized_col_headers,
203
+ row_headers=normalized_row_headers,
204
+ header_cells=header_cells,
205
+ thead_rows=table.thead_rows,
206
+ tbody_rows=table.tbody_rows,
207
+ tfoot_rows=table.tfoot_rows,
208
+ column_scope_rows=table.column_scope_rows,
209
+ row_scope_rows=table.row_scope_rows,
210
+ caption=table.caption,
211
+ context_before=table.context_before,
212
+ context_after=table.context_after,
213
+ )
214
+
215
+
216
+ def cell_score(expected: str, actual: str) -> float:
217
+ """Score similarity between two cell values: 1.0 for match, 0.0 otherwise.
218
+
219
+ Assumes both values have already been normalized by normalize_table()
220
+ (normalize_text + _normalize_trm_cell_text applied upfront).
221
+
222
+ Equality is checked leader-insensitively: two cells that differ only in a
223
+ trailing run of dots/periods ("Acme Inc." vs "Acme Inc", "3." vs "3") are
224
+ the same cell. Only the *tail* is discounted, so "3.1.4" never collapses
225
+ onto "3.14" and "192.168.1.1" never onto "192.168.1" — see
226
+ ``utils.cells_match_leader_insensitive`` for why this lives here rather
227
+ than in the normalizer.
228
+ """
229
+ if _is_empty_or_nan(expected) and _is_empty_or_nan(actual):
230
+ return 1.0
231
+ if _is_empty_or_nan(expected) or _is_empty_or_nan(actual):
232
+ return 0.0
233
+ return 1.0 if cells_match_leader_insensitive(expected, actual, normalize=_normalize_trm_cell_text) else 0.0
234
+
235
+
236
+ # ===========================================================================
237
+ # Phase 2: Header Extraction & Column Alignment
238
+ # ===========================================================================
239
+
240
+
241
+ def _disambiguate_keys(raw_keys: list[str]) -> list[str]:
242
+ """Append _1, _2, … suffixes to duplicate key strings."""
243
+ seen: dict[str, int] = {}
244
+ result: list[str] = []
245
+ for k in raw_keys:
246
+ if k in seen:
247
+ seen[k] += 1
248
+ result.append(f"{k}_{seen[k]}")
249
+ else:
250
+ seen[k] = 0
251
+ result.append(k)
252
+ # Retroactively fix first occurrence if duplicated
253
+ first_idx: dict[str, int] = {}
254
+ for i, k in enumerate(raw_keys):
255
+ if k not in first_idx:
256
+ first_idx[k] = i
257
+ for k, count in seen.items():
258
+ if count > 0 and first_idx[k] < len(result):
259
+ result[first_idx[k]] = f"{k}_0"
260
+ return result
261
+
262
+
263
+ def _build_column_keys(
264
+ table: TableData,
265
+ col_header_rows: set[int],
266
+ title_rows: set[int],
267
+ ) -> tuple[list[str], frozenset[str]]:
268
+ """Build column key strings from header rows, excluding title rows.
269
+
270
+ Multi-level headers are flattened by joining with KEY_SEPARATOR.
271
+ Columns without headers get synthetic keys (col_0, col_1, ...).
272
+ Duplicate keys are disambiguated with _0, _1, ... suffixes.
273
+
274
+ Returns (keys, synthetic_keys) where synthetic_keys is the set of
275
+ key strings that are positional placeholders rather than real header text.
276
+ """
277
+ n_cols = table.data.shape[1]
278
+ raw_keys: list[str] = []
279
+ synthetic_indices: set[int] = set()
280
+ for c in range(n_cols):
281
+ if c in table.col_headers and table.col_headers[c]:
282
+ parts = [
283
+ (row_idx, text)
284
+ for row_idx, text in table.col_headers[c]
285
+ if row_idx in col_header_rows and row_idx not in title_rows
286
+ ]
287
+ parts.sort(key=lambda t: t[0])
288
+ key = KEY_SEPARATOR.join(text for _, text in parts if text.strip())
289
+ if not key:
290
+ key = f"{SYNTHETIC_KEY_PREFIX}{c}"
291
+ synthetic_indices.add(c)
292
+ else:
293
+ key = f"{SYNTHETIC_KEY_PREFIX}{c}"
294
+ synthetic_indices.add(c)
295
+ raw_keys.append(key)
296
+
297
+ keys = _disambiguate_keys(raw_keys)
298
+ synthetic_keys = frozenset(keys[c] for c in synthetic_indices)
299
+ return keys, synthetic_keys
300
+
301
+
302
+ def extract_header_info_from_hints(table: TableData, hints: HeaderHints) -> HeaderInfo:
303
+ """Build a HeaderInfo from a trimmed table and its precomputed hints.
304
+
305
+ The strip stage already removed top ``<td>`` and top ``<th>`` title
306
+ rows from the grid, so ``td_title_rows`` is always empty in trimmed
307
+ coordinates. ``col_header_rows``, ``th_title_rows``, and
308
+ ``stripped_titles`` are taken verbatim from ``hints``; only ``keys``
309
+ is re-derived here. ``stripped_titles`` was already collected from
310
+ a normalized table inside the strip stage, so re-applying the
311
+ per-cell normalization chain here would double-normalize — and the
312
+ chain is not idempotent for inputs like numeric strings with
313
+ separators (e.g. ``"1,2,3" → "12,3" → "123"``), which would break
314
+ prefix-stripping in ``_align_columns_header``.
315
+ """
316
+ if table.data.size == 0:
317
+ return HeaderInfo(
318
+ keys=[],
319
+ synthetic_keys=frozenset(),
320
+ col_header_rows=set(),
321
+ th_title_rows=set(),
322
+ td_title_rows=set(),
323
+ stripped_titles=set(),
324
+ )
325
+
326
+ col_header_rows = set(hints.col_header_rows)
327
+ th_title_rows = set(hints.th_title_rows)
328
+ keys, synthetic_keys = _build_column_keys(table, col_header_rows, th_title_rows)
329
+ stripped_titles = {t for t in hints.stripped_titles if t}
330
+
331
+ return HeaderInfo(
332
+ keys=keys,
333
+ synthetic_keys=synthetic_keys,
334
+ col_header_rows=col_header_rows,
335
+ th_title_rows=th_title_rows,
336
+ td_title_rows=set(),
337
+ stripped_titles=stripped_titles,
338
+ )
339
+
340
+
341
+ def _data_row_indices(header: HeaderInfo, n_rows: int) -> list[int]:
342
+ """Return row indices that contain data (not headers or titles).
343
+
344
+ Trailing title rows at the end of a multi-row header block are
345
+ included as data rows (e.g. section headers like "SPINAL DISORDERS").
346
+ """
347
+ emit_title_rows: set[int] = set()
348
+ if len(header.col_header_rows) > 1 and header.th_title_rows:
349
+ last_header = max(header.col_header_rows)
350
+ if last_header in header.th_title_rows:
351
+ emit_title_rows.add(last_header)
352
+
353
+ return [
354
+ r
355
+ for r in range(n_rows)
356
+ if r not in (header.col_header_rows - emit_title_rows) and r not in header.td_title_rows
357
+ ]
358
+
359
+
360
+ def align_columns(
361
+ gt_keys: list[str],
362
+ pred_keys: list[str],
363
+ *,
364
+ gt_synthetic: frozenset[str] = frozenset(),
365
+ pred_synthetic: frozenset[str] = frozenset(),
366
+ gt_titles: set[str] | None = None,
367
+ pred_titles: set[str] | None = None,
368
+ ) -> tuple[dict[str, str], float]:
369
+ """Align predicted columns to GT columns using header-based fuzzy matching."""
370
+ if not gt_keys or not pred_keys:
371
+ return {}, 0.0
372
+ return _align_columns_header(
373
+ gt_keys,
374
+ pred_keys,
375
+ gt_synthetic=gt_synthetic,
376
+ pred_synthetic=pred_synthetic,
377
+ gt_titles=gt_titles,
378
+ pred_titles=pred_titles,
379
+ )
380
+
381
+
382
+ def _align_columns_header(
383
+ gt_keys: list[str],
384
+ pred_keys: list[str],
385
+ *,
386
+ gt_synthetic: frozenset[str] = frozenset(),
387
+ pred_synthetic: frozenset[str] = frozenset(),
388
+ gt_titles: set[str] | None = None,
389
+ pred_titles: set[str] | None = None,
390
+ ) -> tuple[dict[str, str], float]:
391
+ """Header-based column alignment using fuzzy key matching.
392
+
393
+ If the initial alignment matches fewer than half the columns, checks
394
+ whether a title stripped from one side is a prefix of the other side's
395
+ keys. If so, strips that prefix and retries alignment.
396
+
397
+ This handles the case where GT has "Months of Development 3" (title
398
+ kept as hierarchical header) but pred has just "3" (title stripped),
399
+ or vice versa.
400
+ """
401
+ mapping, alignment_score = _align_columns_header_core(
402
+ gt_keys, pred_keys, gt_synthetic=gt_synthetic, pred_synthetic=pred_synthetic
403
+ )
404
+
405
+ # If we matched most columns, no fallback needed
406
+ min_cols = min(len(gt_keys), len(pred_keys))
407
+ if min_cols <= 1 or len(mapping) >= min_cols * 0.5:
408
+ return mapping, alignment_score
409
+
410
+ # Try prefix-stripping fallback, but ONLY using titles that were
411
+ # actually stripped from the OTHER side's header rows.
412
+ best_mapping, best_score = mapping, alignment_score
413
+
414
+ # If pred stripped a title, that title might be a prefix in GT keys
415
+ # (GT kept it hierarchical, pred didn't). Try stripping it from GT.
416
+ for title in pred_titles or ():
417
+ prefix = title + KEY_SEPARATOR
418
+ if not any(k.startswith(prefix) for k in gt_keys):
419
+ continue
420
+ stripped_gt = [k[len(prefix) :] if k.startswith(prefix) else k for k in gt_keys]
421
+ # Stripped keys inherit synthetic status from their originals
422
+ stripped_gt_synthetic = frozenset(sk for sk, ok in zip(stripped_gt, gt_keys, strict=True) if ok in gt_synthetic)
423
+ trial_mapping, trial_score = _align_columns_header_core(
424
+ stripped_gt, pred_keys, gt_synthetic=stripped_gt_synthetic, pred_synthetic=pred_synthetic
425
+ )
426
+ # Remap back to original GT keys
427
+ gt_by_stripped = dict(zip(stripped_gt, gt_keys, strict=True))
428
+ trial_mapping = {gt_by_stripped[sk]: pv for sk, pv in trial_mapping.items()}
429
+ if len(trial_mapping) > len(best_mapping):
430
+ best_mapping, best_score = trial_mapping, trial_score
431
+
432
+ # If GT stripped a title, that title might be a prefix in pred keys.
433
+ # Try stripping it from pred.
434
+ for title in gt_titles or ():
435
+ prefix = title + KEY_SEPARATOR
436
+ if not any(k.startswith(prefix) for k in pred_keys):
437
+ continue
438
+ stripped_pred = [k[len(prefix) :] if k.startswith(prefix) else k for k in pred_keys]
439
+ stripped_pred_synthetic = frozenset(
440
+ sk for sk, ok in zip(stripped_pred, pred_keys, strict=True) if ok in pred_synthetic
441
+ )
442
+ trial_mapping, trial_score = _align_columns_header_core(
443
+ gt_keys, stripped_pred, gt_synthetic=gt_synthetic, pred_synthetic=stripped_pred_synthetic
444
+ )
445
+ # Remap back to original pred keys
446
+ pred_by_stripped = dict(zip(stripped_pred, pred_keys, strict=True))
447
+ trial_mapping = {gk: pred_by_stripped[sv] for gk, sv in trial_mapping.items()}
448
+ if len(trial_mapping) > len(best_mapping):
449
+ best_mapping, best_score = trial_mapping, trial_score
450
+
451
+ return best_mapping, best_score
452
+
453
+
454
+ _HIERARCHY_DASH_RE = re.compile(r"\s+[-\u2010\u2011\u2012\u2013\u2014\u2015\u2212]\s+")
455
+
456
+
457
+ def _normalize_key_for_match(key: str) -> str:
458
+ """Collapse a dash-like separator surrounded by spaces into a single space.
459
+
460
+ Used only when comparing column keys for alignment, so that a flattened
461
+ hierarchical header like ``"Group A B"`` matches a model's dash-joined
462
+ rendering ``"Group A - B"``. Real keys are stored unchanged; only the
463
+ similarity input is normalized. Covers ASCII hyphen plus the common
464
+ Unicode hyphen/dash characters (en dash, em dash, minus, etc.).
465
+ """
466
+ return _HIERARCHY_DASH_RE.sub(" ", key).lower()
467
+
468
+
469
+ def _align_columns_header_core(
470
+ gt_keys: list[str],
471
+ pred_keys: list[str],
472
+ *,
473
+ gt_synthetic: frozenset[str] = frozenset(),
474
+ pred_synthetic: frozenset[str] = frozenset(),
475
+ ) -> tuple[dict[str, str], float]:
476
+ """Core header-based column alignment using fuzzy key matching."""
477
+ n_gt = len(gt_keys)
478
+ n_pred = len(pred_keys)
479
+ gt_norm = [_normalize_key_for_match(k) for k in gt_keys]
480
+ pred_norm = [_normalize_key_for_match(k) for k in pred_keys]
481
+ sim = np.zeros((n_gt, n_pred))
482
+ for i, gk in enumerate(gt_keys):
483
+ for j, pk in enumerate(pred_keys):
484
+ gk_empty = _is_empty_or_nan(gk) or gk in gt_synthetic
485
+ pk_empty = _is_empty_or_nan(pk) or pk in pred_synthetic
486
+ if gk_empty != pk_empty:
487
+ sim[i, j] = 0.0 # empty vs non-empty = no match
488
+ elif not gk_empty and cells_match_leader_insensitive(gt_norm[i], pred_norm[j]):
489
+ # Two keys that differ only by a trailing dot/period run name
490
+ # the same column. The fuzzy ratio alone does not always say
491
+ # so: it is length-relative, and a short key pair such as
492
+ # "no." / "no" scores 0.80, under the 0.9 threshold, which
493
+ # would unmatch the column and zero every cell in it. Cells
494
+ # are already compared this way in ``cell_score``; keying has
495
+ # to agree or a row pairs on cells it then cannot score.
496
+ # ``gk_empty`` is false here and equals ``pk_empty``, so both
497
+ # keys are non-empty — an empty pair keeps its fuzzy score.
498
+ sim[i, j] = 1.0
499
+ else:
500
+ sim[i, j] = fuzz.ratio(gt_norm[i], pred_norm[j]) / 100.0
501
+
502
+ row_ind, col_ind = linear_sum_assignment(-sim)
503
+
504
+ mapping: dict[str, str] = {}
505
+ scores: list[float] = []
506
+ for r, c in zip(row_ind, col_ind, strict=True):
507
+ if sim[r, c] >= _COLUMN_MATCH_THRESHOLD:
508
+ mapping[gt_keys[r]] = pred_keys[c]
509
+ scores.append(sim[r, c])
510
+
511
+ alignment_score = float(np.mean(scores)) if scores else 0.0
512
+ return mapping, alignment_score
513
+
514
+
515
+ # ===========================================================================
516
+ # Phase 3: Record Generation
517
+ # ===========================================================================
518
+
519
+
520
+ def build_records(table: TableData, header: HeaderInfo) -> list[dict[str, str]]:
521
+ """Build data records from non-header, non-title rows.
522
+
523
+ Uses _data_row_indices for row selection.
524
+ """
525
+ if table.data.size == 0:
526
+ return []
527
+ n_rows, n_cols = table.data.shape
528
+ data_rows = _data_row_indices(header, n_rows)
529
+
530
+ records: list[dict[str, str]] = []
531
+ for r in data_rows:
532
+ record: dict[str, str] = {}
533
+ for col_idx, key in zip(range(n_cols), header.keys, strict=True):
534
+ record[key] = str(table.data[r, col_idx])
535
+ records.append(record)
536
+
537
+ # Remove records where every cell is empty — these are padding artifacts
538
+ # (e.g., trailing rows in merged tables) and should not affect scoring.
539
+ return [r for r in records if not _is_all_empty_record(r)]
540
+
541
+
542
+ def table_to_records(
543
+ table: TableData,
544
+ ) -> tuple[list[str], list[dict[str, str]], set[str]]:
545
+ """Convert a TableData into (keys, records, stripped_titles).
546
+
547
+ Convenience wrapper combining extract_header_info + build_records.
548
+ """
549
+ header = extract_header_info(table)
550
+ records = build_records(table, header)
551
+ return header.keys, records, header.stripped_titles
552
+
553
+
554
+ # ===========================================================================
555
+ # Phase 4: Record Comparison & Scoring
556
+ # ===========================================================================
557
+
558
+
559
+ def _record_similarity(
560
+ gt: dict[str, str],
561
+ pred: dict[str, str],
562
+ column_mapping: dict[str, str],
563
+ *,
564
+ gt_keys: list[str] | None = None,
565
+ pred_keys: list[str] | None = None,
566
+ zero_columns: set[str] | None = None,
567
+ ) -> float:
568
+ """Compute similarity between two records using the column mapping.
569
+
570
+ Scores over the union of GT and pred column keys (matched columns
571
+ counted once, unmatched columns from either side counted once each)
572
+ so that both unmatched GT columns and extra pred columns penalize
573
+ the score symmetrically. Matched columns get their cell_score;
574
+ unmatched columns contribute 0.0. Columns in zero_columns are
575
+ forced to 0 (empty header penalty).
576
+ """
577
+ if not column_mapping:
578
+ return 0.0
579
+
580
+ # Union size = matched columns + unmatched GT columns + extra pred columns.
581
+ # (fall back to mapping size for backward compatibility when
582
+ # gt_keys/pred_keys aren't provided)
583
+ n_gt_cols = len(gt_keys) if gt_keys is not None else len(column_mapping)
584
+ n_pred_cols = len(pred_keys) if pred_keys is not None else len(column_mapping)
585
+ n_matched = len(column_mapping)
586
+ n_total = n_matched + (n_gt_cols - n_matched) + (n_pred_cols - n_matched)
587
+ if n_total == 0:
588
+ return 0.0
589
+
590
+ # Score matched columns
591
+ matched_score = 0.0
592
+ for gt_key, pred_key in column_mapping.items():
593
+ if zero_columns and gt_key in zero_columns:
594
+ continue # Force 0 for this column
595
+ gt_val = gt.get(gt_key, "")
596
+ pred_val = pred.get(pred_key, "")
597
+ matched_score += cell_score(gt_val, pred_val)
598
+
599
+ # Unmatched columns (both sides) implicitly contribute 0.0
600
+ return matched_score / n_total
601
+
602
+
603
+ def match_records(
604
+ gt_records: list[dict[str, str]],
605
+ pred_records: list[dict[str, str]],
606
+ column_mapping: dict[str, str],
607
+ *,
608
+ gt_keys: list[str] | None = None,
609
+ pred_keys: list[str] | None = None,
610
+ zero_columns: set[str] | None = None,
611
+ ) -> tuple[list[tuple[int, int, float]], float]:
612
+ """Match GT records to predicted records and compute overall score.
613
+
614
+ Args:
615
+ gt_keys: All GT column keys — unmatched columns penalize the score.
616
+ pred_keys: All pred column keys — extra columns penalize the score.
617
+ zero_columns: GT columns forced to 0 score (empty header penalty).
618
+
619
+ Returns:
620
+ matches: list of (gt_idx, pred_idx, pair_score)
621
+ score: sum(pair_scores) / max(n_gt, n_pred)
622
+ """
623
+ n_gt = len(gt_records)
624
+ n_pred = len(pred_records)
625
+
626
+ if n_gt == 0 and n_pred == 0:
627
+ return [], 1.0
628
+ if n_gt == 0 or n_pred == 0:
629
+ return [], 0.0
630
+
631
+ # Build record similarity matrix
632
+ sim = np.zeros((n_gt, n_pred))
633
+ for i, g in enumerate(gt_records):
634
+ for j, p in enumerate(pred_records):
635
+ sim[i, j] = _record_similarity(
636
+ g, p, column_mapping, gt_keys=gt_keys, pred_keys=pred_keys, zero_columns=zero_columns
637
+ )
638
+
639
+ # Match records via Hungarian algorithm
640
+ matches: list[tuple[int, int, float]] = []
641
+ row_ind, col_ind = linear_sum_assignment(-sim)
642
+ for r, c in zip(row_ind, col_ind, strict=True):
643
+ matches.append((r, c, sim[r, c]))
644
+
645
+ total_score = sum(s for _, _, s in matches)
646
+ score = total_score / max(n_gt, n_pred)
647
+ return matches, score
648
+
649
+
650
+ def build_record_details(
651
+ gt_records: list[dict[str, str]],
652
+ pred_records: list[dict[str, str]],
653
+ column_mapping: dict[str, str],
654
+ matches: list[tuple[int, int, float]],
655
+ *,
656
+ gt_keys: list[str] | None = None,
657
+ pred_keys: list[str] | None = None,
658
+ zero_columns: set[str] | None = None,
659
+ ) -> list[dict[str, Any]]:
660
+ """Build per-record detail dicts for diagnostics.
661
+
662
+ Returns a list of dicts, one per matched pair plus entries for
663
+ unmatched GT/pred records. Each dict contains the expected values,
664
+ predicted values, per-cell scores, and the overall record score.
665
+ """
666
+ # Reverse mapping: pred_key → gt_key for finding extra pred columns
667
+ reverse_mapping = {v: k for k, v in column_mapping.items()}
668
+
669
+ # Unmatched GT keys (no corresponding pred column)
670
+ all_gt_keys = gt_keys if gt_keys is not None else list(column_mapping)
671
+ unmatched_gt_keys = [k for k in all_gt_keys if k not in column_mapping]
672
+
673
+ # Extra pred keys (no corresponding GT column)
674
+ all_pred_keys = pred_keys if pred_keys is not None else list(column_mapping.values())
675
+ extra_pred_keys = [k for k in all_pred_keys if k not in reverse_mapping]
676
+
677
+ details: list[dict[str, Any]] = []
678
+ matched_gt: set[int] = set()
679
+ matched_pred: set[int] = set()
680
+
681
+ for gt_idx, pred_idx, pair_score in matches:
682
+ gt_idx = int(gt_idx)
683
+ pred_idx = int(pred_idx)
684
+ pair_score = float(pair_score)
685
+ matched_gt.add(gt_idx)
686
+ matched_pred.add(pred_idx)
687
+ gt_rec = gt_records[gt_idx]
688
+ pred_rec = pred_records[pred_idx]
689
+
690
+ cells: list[dict[str, Any]] = []
691
+ # Matched columns
692
+ for gt_key, pred_key in column_mapping.items():
693
+ gt_val = gt_rec.get(gt_key, "")
694
+ pred_val = pred_rec.get(pred_key, "")
695
+ if zero_columns and gt_key in zero_columns:
696
+ cs = 0.0
697
+ else:
698
+ cs = cell_score(gt_val, pred_val)
699
+ cells.append(
700
+ {
701
+ "column": gt_key,
702
+ "expected": gt_val,
703
+ "actual": pred_val,
704
+ "score": cs,
705
+ }
706
+ )
707
+ # Unmatched GT columns (missing from pred)
708
+ for gt_key in unmatched_gt_keys:
709
+ gt_val = gt_rec.get(gt_key, "")
710
+ cells.append(
711
+ {
712
+ "column": gt_key,
713
+ "expected": gt_val,
714
+ "actual": None,
715
+ "score": 0.0,
716
+ }
717
+ )
718
+ # Extra pred columns (not in GT)
719
+ for pred_key in extra_pred_keys:
720
+ pred_val = pred_rec.get(pred_key, "")
721
+ cells.append(
722
+ {
723
+ "column": f"[extra] {pred_key}",
724
+ "expected": None,
725
+ "actual": pred_val,
726
+ "score": 0.0,
727
+ }
728
+ )
729
+
730
+ details.append(
731
+ {
732
+ "type": "matched",
733
+ "gt_index": gt_idx,
734
+ "pred_index": pred_idx,
735
+ "score": pair_score,
736
+ "cells": cells,
737
+ }
738
+ )
739
+
740
+ for i, gt_rec in enumerate(gt_records):
741
+ if i not in matched_gt:
742
+ details.append(
743
+ {
744
+ "type": "unmatched_gt",
745
+ "gt_index": i,
746
+ "pred_index": None,
747
+ "score": 0.0,
748
+ "cells": [
749
+ {"column": k, "expected": gt_rec.get(k, ""), "actual": None, "score": 0.0}
750
+ for k in column_mapping
751
+ ],
752
+ }
753
+ )
754
+
755
+ for j, pred_rec in enumerate(pred_records):
756
+ if j not in matched_pred:
757
+ details.append(
758
+ {
759
+ "type": "unmatched_pred",
760
+ "gt_index": None,
761
+ "pred_index": j,
762
+ "score": 0.0,
763
+ "cells": [
764
+ {"column": pk, "expected": None, "actual": pred_rec.get(pk, ""), "score": 0.0}
765
+ for pk in column_mapping.values()
766
+ ],
767
+ }
768
+ )
769
+
770
+ return details
771
+
772
+
773
+ def _build_detail_strings(per_table_details: list[dict[str, Any]]) -> list[str]:
774
+ """Build human-readable detail strings from per-table record details.
775
+
776
+ Produces ``[SECTION:...]`` markers recognised by the detailed report JS
777
+ to render two nested collapsibles: perfect and imperfect matches.
778
+ """
779
+ multi_table = len(per_table_details) > 1
780
+ perfect_lines: list[str] = []
781
+ perfect_columns: list[str] = []
782
+ imperfect_lines: list[str] = []
783
+
784
+ for td in per_table_details:
785
+ table_idx: int = td.get("gt_table_index", 0)
786
+ prefix = f"Table {table_idx + 1}: " if multi_table else ""
787
+ record_details: list[dict[str, Any]] = td.get("record_details", [])
788
+
789
+ # Report entirely unmatched tables (no pred pairing, no column matches, no GT records)
790
+ if not record_details and td.get("score", 0.0) == 0.0:
791
+ reason = td.get("reason", "no prediction")
792
+ pred_idx = td.get("pred_table_index")
793
+ gt_cols: list[str] = td.get("gt_columns", [])
794
+ n_gt_recs: int = td.get("n_gt_records", 0)
795
+
796
+ if pred_idx is None:
797
+ line = f"{prefix}GT table {table_idx + 1} — unmatched ({reason})"
798
+ else:
799
+ line = f"{prefix}GT table {table_idx + 1} ↔ Pred table {pred_idx + 1} — {reason}"
800
+
801
+ if gt_cols:
802
+ line += f", columns: [{', '.join(gt_cols)}]"
803
+ if n_gt_recs > 0:
804
+ line += f", {n_gt_recs} record{'s' if n_gt_recs != 1 else ''}"
805
+
806
+ imperfect_lines.append(line)
807
+ continue
808
+
809
+ for rd in record_details:
810
+ rtype = rd.get("type", "")
811
+ score = rd.get("score", 0.0)
812
+ gt_idx = rd.get("gt_index")
813
+ pred_idx = rd.get("pred_index")
814
+ cells: list[dict[str, Any]] = rd.get("cells", [])
815
+
816
+ if rtype == "unmatched_gt":
817
+ imperfect_lines.append(f"{prefix}GT#{gt_idx} \u2014 unmatched (no prediction)")
818
+ elif rtype == "unmatched_pred":
819
+ imperfect_lines.append(f"{prefix}Pred#{pred_idx} \u2014 extra prediction (no GT match)")
820
+ elif score == 1.0:
821
+ if not perfect_columns and cells:
822
+ perfect_columns = [c.get("column", "?") for c in cells]
823
+ vals = " | ".join(f'"{c.get("expected", "")}"' for c in cells)
824
+ perfect_lines.append(f"{prefix}GT#{gt_idx} \u2194 Pred#{pred_idx} ({score:.3f}) \u2014 {vals}")
825
+ else:
826
+ # Show only imperfect cells to keep lines compact
827
+ diffs = []
828
+ for c in cells:
829
+ if c.get("score", 1.0) < 1.0:
830
+ exp = c.get("expected", "")
831
+ act = c.get("actual", "")
832
+ cscore = c.get("score", 0.0)
833
+ col = c.get("column", "?")
834
+ diffs.append(f'{col}: "{exp}"\u2192"{act}" ({cscore:.2f})')
835
+ diff_str = " | ".join(diffs) if diffs else "all cells differ"
836
+ imperfect_lines.append(f"{prefix}GT#{gt_idx} \u2194 Pred#{pred_idx} ({score:.3f}) \u2014 {diff_str}")
837
+
838
+ lines: list[str] = []
839
+ if perfect_lines:
840
+ lines.append(f"[SECTION:Perfect Record Matches ({len(perfect_lines)})]")
841
+ if perfect_columns:
842
+ lines.append("Columns: " + " | ".join(perfect_columns))
843
+ lines.extend(perfect_lines)
844
+ if imperfect_lines:
845
+ lines.append(f"[SECTION:Imperfect Record Matches ({len(imperfect_lines)})]")
846
+ lines.extend(imperfect_lines)
847
+ return lines
848
+
849
+
850
+ # ===========================================================================
851
+ # Header Recovery, Demotion & Promotion Helpers
852
+ # ===========================================================================
853
+
854
+
855
+ def _try_section_header_promotion(
856
+ table: TableData,
857
+ header: HeaderInfo,
858
+ ) -> HeaderInfo | None:
859
+ """Try promoting the row right below the header block to a header row.
860
+
861
+ Returns a new HeaderInfo with the row promoted, or None if no candidate exists.
862
+ The candidate row must be a section-header-like row (spanning, single value)
863
+ immediately after the header block.
864
+ """
865
+ if not header.col_header_rows:
866
+ return None
867
+ n_rows = table.data.shape[0]
868
+ candidate_row = max(header.col_header_rows) + 1
869
+ if candidate_row >= n_rows:
870
+ return None
871
+ # Check if it's a section-header-like row (width-spanning, single value)
872
+ n_cols = table.data.shape[1]
873
+ row_values = [str(table.data[candidate_row, c]).strip() for c in range(n_cols)]
874
+ unique_nonempty = {v for v in row_values if v}
875
+ if len(unique_nonempty) != 1:
876
+ return None
877
+ # Rebuild header info with this row included in col_header_rows
878
+ new_col_header_rows = header.col_header_rows | {candidate_row}
879
+ new_keys, new_synthetic = _build_column_keys(table, new_col_header_rows, header.th_title_rows)
880
+ return HeaderInfo(
881
+ keys=new_keys,
882
+ synthetic_keys=new_synthetic,
883
+ col_header_rows=new_col_header_rows,
884
+ th_title_rows=header.th_title_rows,
885
+ td_title_rows=header.td_title_rows,
886
+ stripped_titles=header.stripped_titles,
887
+ )
888
+
889
+
890
+ def _try_recover_pred_header(
891
+ gt_header: HeaderInfo,
892
+ pred_table: TableData,
893
+ pred_header: HeaderInfo,
894
+ ) -> HeaderInfo | None:
895
+ """Try to recover pred headers from first data row when pred has no real headers.
896
+
897
+ If GT has real headers and pred has all synthetic keys, check if pred's
898
+ first data row matches any GT header. If any column matches, promote
899
+ the entire first row to a header row.
900
+
901
+ Returns new HeaderInfo with recovered headers, or None if recovery fails.
902
+ """
903
+ # Only trigger when GT has real headers and pred has all synthetic
904
+ gt_has_real = bool(set(gt_header.keys) - gt_header.synthetic_keys)
905
+ pred_all_synthetic = set(pred_header.keys) <= pred_header.synthetic_keys
906
+ if not gt_has_real or not pred_all_synthetic:
907
+ return None
908
+
909
+ n_rows, n_cols = pred_table.data.shape
910
+ data_rows = _data_row_indices(pred_header, n_rows)
911
+ if not data_rows:
912
+ return None
913
+
914
+ first_data_row = data_rows[0]
915
+ first_row_values = [str(pred_table.data[first_data_row, c]).strip() for c in range(n_cols)]
916
+
917
+ # Check if any value in the first row matches a GT header key
918
+ gt_real_keys = {k.lower() for k in gt_header.keys if k not in gt_header.synthetic_keys}
919
+ has_match = any(
920
+ fuzz.ratio(val.lower(), gk) / 100.0 >= _COLUMN_MATCH_THRESHOLD
921
+ for val in first_row_values
922
+ if val
923
+ for gk in gt_real_keys
924
+ )
925
+
926
+ if not has_match:
927
+ return None
928
+
929
+ # Promote first data row to header
930
+ new_col_header_rows = pred_header.col_header_rows | {first_data_row}
931
+ raw_keys = [val if val else f"{SYNTHETIC_KEY_PREFIX}{c}" for c, val in enumerate(first_row_values)]
932
+ new_keys = _disambiguate_keys(raw_keys)
933
+ new_synthetic = frozenset(k for c, k in enumerate(new_keys) if not first_row_values[c])
934
+
935
+ return HeaderInfo(
936
+ keys=new_keys,
937
+ synthetic_keys=new_synthetic,
938
+ col_header_rows=new_col_header_rows,
939
+ th_title_rows=pred_header.th_title_rows,
940
+ td_title_rows=pred_header.td_title_rows,
941
+ stripped_titles=pred_header.stripped_titles,
942
+ )
943
+
944
+
945
+ def _demote_pred_headers(
946
+ gt_header: HeaderInfo,
947
+ pred_table: TableData,
948
+ pred_header: HeaderInfo,
949
+ ) -> HeaderInfo:
950
+ """Demote pred headers to data when GT has no real headers.
951
+
952
+ GT is authoritative — if GT says no headers, pred's <th> tags are irrelevant.
953
+ Returns new HeaderInfo with all header rows cleared and synthetic keys.
954
+ """
955
+ gt_all_synthetic = set(gt_header.keys) <= gt_header.synthetic_keys
956
+ pred_has_real = bool(set(pred_header.keys) - pred_header.synthetic_keys)
957
+
958
+ if not gt_all_synthetic or not pred_has_real:
959
+ return pred_header # No demotion needed
960
+
961
+ n_cols = pred_table.data.shape[1]
962
+ demoted_keys = [f"{SYNTHETIC_KEY_PREFIX}{c}" for c in range(n_cols)]
963
+
964
+ return HeaderInfo(
965
+ keys=demoted_keys,
966
+ synthetic_keys=frozenset(demoted_keys),
967
+ col_header_rows=set(), # No header rows
968
+ th_title_rows=set(), # Title rows also become data
969
+ td_title_rows=set(), # Title rows also become data
970
+ stripped_titles=set(),
971
+ )
972
+
973
+
974
+ # ===========================================================================
975
+ # Phase 5: Side-by-Side Table Splitting
976
+ # ===========================================================================
977
+
978
+
979
+ def _resolve_header_row_values(
980
+ table: TableData,
981
+ header: HeaderInfo,
982
+ ) -> list[list[str]]:
983
+ """Extract per-column header values for each header row, resolving colspan.
984
+
985
+ Returns a list of rows, where each row is a list of normalized cell texts
986
+ (one per column). Colspan'd cells appear with their text repeated for each
987
+ column they cover (since col_headers already stores per-column entries).
988
+
989
+ Only includes rows in header.col_header_rows, sorted top-to-bottom.
990
+ """
991
+ n_cols = table.data.shape[1]
992
+
993
+ # Build (col, row) → text lookup for O(1) access
994
+ header_lookup: dict[tuple[int, int], str] = {}
995
+ for col_idx, entries in table.col_headers.items():
996
+ for row_idx, text in entries:
997
+ header_lookup[(col_idx, row_idx)] = _normalize_trm_table_text(text.strip())
998
+
999
+ result: list[list[str]] = []
1000
+ for row_idx in sorted(header.col_header_rows):
1001
+ result.append([header_lookup.get((c, row_idx), "") for c in range(n_cols)])
1002
+ return result
1003
+
1004
+
1005
+ # ===========================================================================
1006
+ # Metric Orchestrator
1007
+ # ===========================================================================
1008
+
1009
+
1010
+ class TableRecordMatchMetric:
1011
+ """Compute table_record_match: semantic equivalence of HTML tables as record collections."""
1012
+
1013
+ def _score_table_pair(
1014
+ self,
1015
+ gt_table: TableData,
1016
+ pred_table: TableData,
1017
+ table_index: int,
1018
+ *,
1019
+ gt_hints: HeaderHints,
1020
+ pred_hints: HeaderHints,
1021
+ ) -> tuple[float, dict[str, Any]]:
1022
+ """Score a single GT/pred table pair.
1023
+
1024
+ Pipeline:
1025
+ 1. Normalize cell text in both tables
1026
+ 2. Build header info from precomputed hints, align columns
1027
+ 3. Generate records from both tables
1028
+ 4. Match and score records
1029
+ """
1030
+ # Phase 1: Normalize
1031
+ gt_table = normalize_table(gt_table)
1032
+ pred_table = normalize_table(pred_table)
1033
+
1034
+ # Phase 2: Header extraction & column alignment
1035
+ gt_header = extract_header_info_from_hints(gt_table, gt_hints)
1036
+ pred_header = extract_header_info_from_hints(pred_table, pred_hints)
1037
+
1038
+ # Header recovery: if GT has headers and pred doesn't, try promoting pred's first row
1039
+ recovered = _try_recover_pred_header(gt_header, pred_table, pred_header)
1040
+ if recovered is not None:
1041
+ pred_header = recovered
1042
+
1043
+ # Header demotion: if GT has no headers, demote pred's headers to data
1044
+ pred_header = _demote_pred_headers(gt_header, pred_table, pred_header)
1045
+
1046
+ col_mapping, alignment_score = align_columns(
1047
+ gt_header.keys,
1048
+ pred_header.keys,
1049
+ gt_synthetic=gt_header.synthetic_keys,
1050
+ pred_synthetic=pred_header.synthetic_keys,
1051
+ gt_titles=gt_header.stripped_titles,
1052
+ pred_titles=pred_header.stripped_titles,
1053
+ )
1054
+
1055
+ # Try section header promotion for both tables
1056
+ for table, header, is_gt in [(gt_table, gt_header, True), (pred_table, pred_header, False)]:
1057
+ promoted = _try_section_header_promotion(table, header)
1058
+ if promoted is None:
1059
+ continue
1060
+
1061
+ if is_gt:
1062
+ trial_gt_keys = promoted.keys
1063
+ trial_pred_keys = pred_header.keys
1064
+ trial_gt_synthetic = promoted.synthetic_keys
1065
+ trial_pred_synthetic = pred_header.synthetic_keys
1066
+ trial_gt_titles = promoted.stripped_titles
1067
+ trial_pred_titles = pred_header.stripped_titles
1068
+ else:
1069
+ trial_gt_keys = gt_header.keys
1070
+ trial_pred_keys = promoted.keys
1071
+ trial_gt_synthetic = gt_header.synthetic_keys
1072
+ trial_pred_synthetic = promoted.synthetic_keys
1073
+ trial_gt_titles = gt_header.stripped_titles
1074
+ trial_pred_titles = promoted.stripped_titles
1075
+
1076
+ trial_mapping, trial_score = align_columns(
1077
+ trial_gt_keys,
1078
+ trial_pred_keys,
1079
+ gt_synthetic=trial_gt_synthetic,
1080
+ pred_synthetic=trial_pred_synthetic,
1081
+ gt_titles=trial_gt_titles,
1082
+ pred_titles=trial_pred_titles,
1083
+ )
1084
+ if len(trial_mapping) > len(col_mapping) or (
1085
+ len(trial_mapping) == len(col_mapping) and trial_score > alignment_score
1086
+ ):
1087
+ col_mapping, alignment_score = trial_mapping, trial_score
1088
+ if is_gt:
1089
+ gt_header = promoted
1090
+ else:
1091
+ pred_header = promoted
1092
+
1093
+ # Hard gate: no columns matched → 0
1094
+ if not col_mapping:
1095
+ gt_real_keys = [k for k in gt_header.keys if k not in gt_header.synthetic_keys]
1096
+ n_gt_recs = len(build_records(gt_table, gt_header))
1097
+ return 0.0, {
1098
+ "gt_table_index": table_index,
1099
+ "pred_table_index": table_index,
1100
+ "score": 0.0,
1101
+ "reason": "no column matches",
1102
+ "gt_columns": gt_real_keys if gt_real_keys else gt_header.keys,
1103
+ "n_gt_records": n_gt_recs,
1104
+ }
1105
+
1106
+ # Compute zero_columns: GT columns with real headers matched to empty/synthetic pred headers
1107
+ zero_columns: set[str] = set()
1108
+ for gt_key, pred_key in col_mapping.items():
1109
+ gt_is_real = gt_key not in gt_header.synthetic_keys and not _is_empty_or_nan(gt_key)
1110
+ pred_is_empty = pred_key in pred_header.synthetic_keys or _is_empty_or_nan(pred_key)
1111
+ if gt_is_real and pred_is_empty:
1112
+ zero_columns.add(gt_key)
1113
+
1114
+ # Phase 3: Record generation
1115
+ gt_records = build_records(gt_table, gt_header)
1116
+ pred_records = build_records(pred_table, pred_header)
1117
+
1118
+ if not gt_records:
1119
+ return 0.0, {
1120
+ "gt_table_index": table_index,
1121
+ "pred_table_index": table_index,
1122
+ "score": 0.0,
1123
+ "reason": "no GT records",
1124
+ "gt_columns": gt_header.keys,
1125
+ "n_gt_records": 0,
1126
+ }
1127
+
1128
+ # Phase 4: Record comparison & scoring
1129
+ matches, score = match_records(
1130
+ gt_records,
1131
+ pred_records,
1132
+ col_mapping,
1133
+ gt_keys=gt_header.keys,
1134
+ pred_keys=pred_header.keys,
1135
+ zero_columns=zero_columns if zero_columns else None,
1136
+ )
1137
+ record_details = build_record_details(
1138
+ gt_records,
1139
+ pred_records,
1140
+ col_mapping,
1141
+ matches,
1142
+ gt_keys=gt_header.keys,
1143
+ pred_keys=pred_header.keys,
1144
+ zero_columns=zero_columns if zero_columns else None,
1145
+ )
1146
+
1147
+ return score, {
1148
+ "gt_table_index": table_index,
1149
+ "pred_table_index": table_index,
1150
+ "score": score,
1151
+ "alignment_score": alignment_score,
1152
+ "n_gt_records": len(gt_records),
1153
+ "n_pred_records": len(pred_records),
1154
+ "n_matched_columns": len(col_mapping),
1155
+ "column_mapping": col_mapping,
1156
+ "record_details": record_details,
1157
+ }
1158
+
1159
+ @property
1160
+ def metric_name(self) -> str:
1161
+ return "table_record_match"
1162
+
1163
+ @staticmethod
1164
+ def _extract_grits_table_pairing(
1165
+ grits_results: list[MetricValue] | None,
1166
+ ) -> list[tuple[int, int | None]] | None:
1167
+ """Extract GT→pred table pairing from GriTS metadata.
1168
+
1169
+ Returns list of (gt_idx, pred_idx_or_None) sorted by gt_idx,
1170
+ or None if GriTS results aren't available.
1171
+ """
1172
+ if not grits_results:
1173
+ return None
1174
+ for r in grits_results:
1175
+ if r.metadata and "per_table_details" in r.metadata:
1176
+ details = r.metadata["per_table_details"]
1177
+ return [(d["gt_table_index"], d.get("pred_table_index")) for d in details]
1178
+ return None
1179
+
1180
+ @staticmethod
1181
+ def _compute_grits_table_pairing(
1182
+ expected_tables: list[str],
1183
+ actual_tables: list[str],
1184
+ ) -> list[tuple[int, int | None]]:
1185
+ """Compute GT→pred table pairing via GriTS-Con Hungarian matching.
1186
+
1187
+ Runs GriTS-Con on all (GT × pred) table pairs, then uses the
1188
+ Hungarian algorithm to find the optimal 1:1 assignment.
1189
+ """
1190
+ from parse_bench.evaluation.metrics.parse.grits_metric import (
1191
+ grits_from_html,
1192
+ )
1193
+
1194
+ n_gt = len(expected_tables)
1195
+ n_pred = len(actual_tables)
1196
+ cost_matrix = np.zeros((n_gt, n_pred))
1197
+
1198
+ for i, gt_html in enumerate(expected_tables):
1199
+ for j, pred_html in enumerate(actual_tables):
1200
+ result = grits_from_html(gt_html, pred_html)
1201
+ score = result["grits_con"] if result else 0.0
1202
+ cost_matrix[i, j] = -score
1203
+
1204
+ row_ind, col_ind = linear_sum_assignment(cost_matrix)
1205
+
1206
+ matched: dict[int, int] = {}
1207
+ for r, c in zip(row_ind, col_ind, strict=True):
1208
+ matched[int(r)] = int(c)
1209
+
1210
+ pairing: list[tuple[int, int | None]] = []
1211
+ for i in range(n_gt):
1212
+ pairing.append((i, matched.get(i)))
1213
+ return pairing
1214
+
1215
+ def compute_extracted(
1216
+ self,
1217
+ expected_tables: list[Any],
1218
+ actual_tables: list[Any],
1219
+ *,
1220
+ pairing: list[tuple[int, int | None]],
1221
+ ) -> list[MetricValue]:
1222
+ """Score TRM from pre-extracted ExtractedTable lists + GriTS pairing.
1223
+
1224
+ New entry point for the unified table-extraction pipeline (P3+).
1225
+ TRM still owns its normalization — ``normalize_table`` is applied
1226
+ per pair inside ``_score_table_pair``, so scores are numerically
1227
+ equal to the legacy ``compute(str, str)`` path.
1228
+ """
1229
+ # The strip_title_rows stage runs upstream in the evaluator and
1230
+ # populates ``header_hints``. If a caller bypasses that stage,
1231
+ # apply it lazily here so this entry point stays self-contained.
1232
+ expected_tables = [et if et.header_hints is not None else strip_title_rows(et) for et in expected_tables]
1233
+ actual_tables = [et if et.header_hints is not None else strip_title_rows(et) for et in actual_tables]
1234
+
1235
+ gt_parsed = [et.table_data for et in expected_tables]
1236
+ pred_parsed = [et.table_data for et in actual_tables]
1237
+ gt_hints_list: list[HeaderHints] = [et.header_hints for et in expected_tables]
1238
+ pred_hints_list: list[HeaderHints] = [et.header_hints for et in actual_tables]
1239
+
1240
+ if not gt_parsed:
1241
+ return []
1242
+
1243
+ if not pred_parsed:
1244
+ return [
1245
+ MetricValue(
1246
+ metric_name=self.metric_name,
1247
+ value=0.0,
1248
+ metadata={
1249
+ "tables_predicted": False,
1250
+ "n_gt_tables": len(gt_parsed),
1251
+ "n_pred_tables": 0,
1252
+ },
1253
+ ),
1254
+ MetricValue(
1255
+ metric_name=f"{self.metric_name}_perfect",
1256
+ value=0.0,
1257
+ metadata={
1258
+ "tables_predicted": False,
1259
+ "n_perfect": 0,
1260
+ "n_gt_tables": len(gt_parsed),
1261
+ },
1262
+ ),
1263
+ ]
1264
+
1265
+ n_gt = len(gt_parsed)
1266
+ n_pred = len(pred_parsed)
1267
+
1268
+ # Splitting and any pairing override now happen upstream in the
1269
+ # evaluator (see ``split_ambiguous_merged_pred``); TRM consumes the
1270
+ # GriTS-supplied pairing uniformly, including on split sub-tables.
1271
+ local_pairing: list[tuple[int, int | None]] = list(pairing)
1272
+
1273
+ per_table_scores: list[float] = []
1274
+ per_table_details: list[dict[str, Any]] = []
1275
+
1276
+ for gt_idx, pred_idx in local_pairing:
1277
+ if pred_idx is None or pred_idx >= n_pred or gt_idx >= n_gt:
1278
+ per_table_scores.append(0.0)
1279
+ unmatched_detail: dict[str, Any] = {
1280
+ "gt_table_index": gt_idx,
1281
+ "pred_table_index": None,
1282
+ "score": 0.0,
1283
+ "reason": "no prediction",
1284
+ }
1285
+ if gt_idx < n_gt:
1286
+ gt_t = normalize_table(gt_parsed[gt_idx])
1287
+ gt_h = extract_header_info_from_hints(gt_t, gt_hints_list[gt_idx])
1288
+ gt_r = build_records(gt_t, gt_h)
1289
+ real_keys = [k for k in gt_h.keys if k not in gt_h.synthetic_keys]
1290
+ unmatched_detail["gt_columns"] = real_keys if real_keys else gt_h.keys
1291
+ unmatched_detail["n_gt_records"] = len(gt_r)
1292
+ per_table_details.append(unmatched_detail)
1293
+ continue
1294
+
1295
+ score, detail = self._score_table_pair(
1296
+ gt_parsed[gt_idx],
1297
+ pred_parsed[pred_idx],
1298
+ gt_idx,
1299
+ gt_hints=gt_hints_list[gt_idx],
1300
+ pred_hints=pred_hints_list[pred_idx],
1301
+ )
1302
+ detail["pred_table_index"] = pred_idx
1303
+ per_table_scores.append(score)
1304
+ per_table_details.append(detail)
1305
+
1306
+ avg_score = float(np.mean(per_table_scores)) if per_table_scores else 0.0
1307
+ n_perfect = sum(1 for s in per_table_scores if s == 1.0)
1308
+ perfect_rate = n_perfect / len(per_table_scores) if per_table_scores else 0.0
1309
+
1310
+ return [
1311
+ MetricValue(
1312
+ metric_name=self.metric_name,
1313
+ value=avg_score,
1314
+ metadata={
1315
+ "tables_predicted": True,
1316
+ "n_gt_tables": n_gt,
1317
+ "n_pred_tables": n_pred,
1318
+ "per_table_scores": per_table_scores,
1319
+ "per_table_details": per_table_details,
1320
+ },
1321
+ details=_build_detail_strings(per_table_details),
1322
+ ),
1323
+ MetricValue(
1324
+ metric_name=f"{self.metric_name}_perfect",
1325
+ value=perfect_rate,
1326
+ metadata={
1327
+ "tables_predicted": True,
1328
+ "n_perfect": n_perfect,
1329
+ "n_gt_tables": n_gt,
1330
+ },
1331
+ ),
1332
+ ]
1333
+
1334
+ def compute(self, expected: str, actual: str, **kwargs: Any) -> list[MetricValue]:
1335
+ """Score all tables in expected vs actual HTML/markdown content.
1336
+
1337
+ If ``grits_results`` is passed in kwargs, reuses GriTS's Hungarian
1338
+ table matching. Otherwise computes GriTS-Con matching directly.
1339
+ Skips matching when there's only one table on each side.
1340
+
1341
+ Returns list with a single MetricValue for the metric.
1342
+ """
1343
+ # Use lxml-based parsing as the gatekeeper — it handles malformed
1344
+ # HTML (e.g. missing </table>) more reliably than string scanning.
1345
+ gt_parsed = parse_html_tables(expected)
1346
+ pred_parsed = parse_html_tables(actual)
1347
+
1348
+ if not gt_parsed:
1349
+ return []
1350
+
1351
+ if not pred_parsed:
1352
+ return [
1353
+ MetricValue(
1354
+ metric_name=self.metric_name,
1355
+ value=0.0,
1356
+ metadata={"tables_predicted": False, "n_gt_tables": len(gt_parsed), "n_pred_tables": 0},
1357
+ ),
1358
+ MetricValue(
1359
+ metric_name=f"{self.metric_name}_perfect",
1360
+ value=0.0,
1361
+ metadata={"tables_predicted": False, "n_perfect": 0, "n_gt_tables": len(gt_parsed)},
1362
+ ),
1363
+ ]
1364
+
1365
+ # String-scanned tables are still needed for GriTS-based pairing
1366
+ expected_tables = extract_html_tables(expected)
1367
+ actual_tables = extract_html_tables(actual)
1368
+
1369
+ # Wrap each parsed table in an ExtractedTable and run the strip
1370
+ # stage so this legacy entry point feeds ``_score_table_pair`` the
1371
+ # same trimmed-table + hints payload that ``compute_extracted``
1372
+ # does. Keeps TRM detector-free.
1373
+ gt_extracted = [strip_title_rows(ExtractedTable(raw_html="", table_data=t)) for t in gt_parsed]
1374
+ pred_extracted = [strip_title_rows(ExtractedTable(raw_html="", table_data=t)) for t in pred_parsed]
1375
+ gt_parsed = [et.table_data for et in gt_extracted]
1376
+ pred_parsed = [et.table_data for et in pred_extracted]
1377
+ gt_hints_list: list[HeaderHints] = [et.header_hints for et in gt_extracted] # type: ignore[misc]
1378
+ pred_hints_list: list[HeaderHints] = [et.header_hints for et in pred_extracted] # type: ignore[misc]
1379
+
1380
+ n_gt = len(gt_parsed)
1381
+ n_pred = len(pred_parsed)
1382
+
1383
+ # Determine table pairing via GriTS Hungarian matching (reused from
1384
+ # the evaluator if available, otherwise computed locally). Splitting
1385
+ # of ambiguous merged-pred tables happens upstream in the evaluator
1386
+ # via ``split_ambiguous_merged_pred``; this legacy ``compute(str, str)``
1387
+ # entry point no longer attempts it.
1388
+ grits_results: list[MetricValue] | None = kwargs.get("grits_results")
1389
+ pairing: list[tuple[int, int | None]] | None = self._extract_grits_table_pairing(grits_results)
1390
+ if pairing is None:
1391
+ pairing = self._compute_grits_table_pairing(expected_tables, actual_tables)
1392
+
1393
+ per_table_scores: list[float] = []
1394
+ per_table_details: list[dict[str, Any]] = []
1395
+
1396
+ for gt_idx, pred_idx in pairing:
1397
+ if pred_idx is None or pred_idx >= n_pred or gt_idx >= n_gt:
1398
+ per_table_scores.append(0.0)
1399
+ unmatched_detail: dict[str, Any] = {
1400
+ "gt_table_index": gt_idx,
1401
+ "pred_table_index": None,
1402
+ "score": 0.0,
1403
+ "reason": "no prediction",
1404
+ }
1405
+ # Include GT table info for the detail report
1406
+ if gt_idx < n_gt:
1407
+ gt_t = normalize_table(gt_parsed[gt_idx])
1408
+ gt_h = extract_header_info_from_hints(gt_t, gt_hints_list[gt_idx])
1409
+ gt_r = build_records(gt_t, gt_h)
1410
+ real_keys = [k for k in gt_h.keys if k not in gt_h.synthetic_keys]
1411
+ unmatched_detail["gt_columns"] = real_keys if real_keys else gt_h.keys
1412
+ unmatched_detail["n_gt_records"] = len(gt_r)
1413
+ per_table_details.append(unmatched_detail)
1414
+ continue
1415
+
1416
+ score, detail = self._score_table_pair(
1417
+ gt_parsed[gt_idx],
1418
+ pred_parsed[pred_idx],
1419
+ gt_idx,
1420
+ gt_hints=gt_hints_list[gt_idx],
1421
+ pred_hints=pred_hints_list[pred_idx],
1422
+ )
1423
+ detail["pred_table_index"] = pred_idx
1424
+ per_table_scores.append(score)
1425
+ per_table_details.append(detail)
1426
+
1427
+ avg_score = float(np.mean(per_table_scores)) if per_table_scores else 0.0
1428
+ n_perfect = sum(1 for s in per_table_scores if s == 1.0)
1429
+ perfect_rate = n_perfect / len(per_table_scores) if per_table_scores else 0.0
1430
+
1431
+ return [
1432
+ MetricValue(
1433
+ metric_name=self.metric_name,
1434
+ value=avg_score,
1435
+ metadata={
1436
+ "tables_predicted": True,
1437
+ "n_gt_tables": n_gt,
1438
+ "n_pred_tables": n_pred,
1439
+ "per_table_scores": per_table_scores,
1440
+ "per_table_details": per_table_details,
1441
+ },
1442
+ details=_build_detail_strings(per_table_details),
1443
+ ),
1444
+ MetricValue(
1445
+ metric_name=f"{self.metric_name}_perfect",
1446
+ value=perfect_rate,
1447
+ metadata={
1448
+ "tables_predicted": True,
1449
+ "n_perfect": n_perfect,
1450
+ "n_gt_tables": n_gt,
1451
+ },
1452
+ ),
1453
+ ]