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,1125 @@
1
+ """GriTS (Grid Table Similarity) metric for HTML table comparison.
2
+
3
+ Computes content similarity between HTML tables using a grid-based
4
+ representation. GriTS_Con evaluates tables in their natural matrix form
5
+ via the factored 2D most-similar substructures (2D-MSS) algorithm.
6
+
7
+ Core algorithm adapted from the reference implementation at:
8
+ https://github.com/microsoft/table-transformer/blob/main/src/grits.py
9
+
10
+ Reference paper:
11
+ Smock, Pesala, Abraham. "GriTS: Grid Table Similarity Metric for
12
+ Table Structure Recognition." ICDAR 2023.
13
+ https://arxiv.org/abs/2203.12555
14
+ """
15
+
16
+ import itertools
17
+ import os
18
+ from collections import defaultdict
19
+ from dataclasses import replace
20
+ from difflib import SequenceMatcher
21
+ from functools import lru_cache
22
+ from typing import Any, Literal, overload
23
+
24
+ import numpy as np
25
+ from lxml import html
26
+
27
+ from parse_bench.evaluation.metrics.base import Metric
28
+ from parse_bench.evaluation.metrics.parse.table_pairing import assign_tables, page_blocking_active
29
+ from parse_bench.evaluation.metrics.parse.table_parsing import (
30
+ _ASCII_TO_SUBSCRIPT,
31
+ _ASCII_TO_SUPERSCRIPT,
32
+ TableData,
33
+ )
34
+ from parse_bench.evaluation.metrics.parse.utils import normalize_cell_text
35
+ from parse_bench.schemas.evaluation import MetricValue
36
+
37
+ # `pairing` schema in MetricValue.metadata (load-bearing for TRM consumption):
38
+ # list[tuple[int, int | None]] of length n_gt
39
+ # (gt_idx, pred_idx) for matched, (gt_idx, None) for unmatched GT.
40
+
41
+ # When one table has many cells AND the row/column counts differ by more
42
+ # than this factor, skip GriTS — the prediction is structurally wrong and
43
+ # the O(R1*C1*R2*C2) algorithm would take minutes for no useful signal.
44
+ DEFAULT_MIN_CELLS_FOR_MISMATCH_SKIP = 2500
45
+ DEFAULT_MAX_DIMENSION_RATIO = 1.5
46
+ DEFAULT_MISMATCH_SKIP_SCORE = 0.0
47
+
48
+ # =============================================================================
49
+ # Core GriTS algorithm (adapted from microsoft/table-transformer)
50
+ # =============================================================================
51
+
52
+
53
+ def _is_scalar(val: Any) -> bool:
54
+ """Check if a value is a scalar (unoccupied grid cell), not a bbox list."""
55
+ try:
56
+ len(val)
57
+ return False
58
+ except TypeError:
59
+ return True
60
+
61
+
62
+ def _bbox_iou(bbox1: Any, bbox2: Any) -> float:
63
+ """Compute intersection-over-union of two [x1, y1, x2, y2] bounding boxes.
64
+
65
+ Uses bounding-box union (area of the smallest enclosing rectangle) to
66
+ match the reference GriTS implementation, which uses PyMuPDF
67
+ Rect.include_rect for the union.
68
+
69
+ Handles numpy arrays and scalar 0 (unoccupied grid cells).
70
+ """
71
+ bbox1_scalar = _is_scalar(bbox1)
72
+ bbox2_scalar = _is_scalar(bbox2)
73
+
74
+ # Both unoccupied → both tables agree "no cell here" → perfect match
75
+ if bbox1_scalar and bbox2_scalar:
76
+ return 1.0
77
+ # One occupied, one not → structural mismatch
78
+ if bbox1_scalar or bbox2_scalar:
79
+ return 0.0
80
+
81
+ if len(bbox1) != 4 or len(bbox2) != 4:
82
+ return 0.0
83
+
84
+ # Intersection rectangle
85
+ x1 = max(bbox1[0], bbox2[0])
86
+ y1 = max(bbox1[1], bbox2[1])
87
+ x2 = min(bbox1[2], bbox2[2])
88
+ y2 = min(bbox1[3], bbox2[3])
89
+
90
+ intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1)
91
+ if intersection == 0.0:
92
+ return 0.0
93
+
94
+ # Bounding-box union (smallest enclosing rectangle)
95
+ union = (max(bbox1[2], bbox2[2]) - min(bbox1[0], bbox2[0])) * (max(bbox1[3], bbox2[3]) - min(bbox1[1], bbox2[1]))
96
+
97
+ if union <= 0:
98
+ return 0.0
99
+ return intersection / union # type: ignore[no-any-return]
100
+
101
+
102
+ @lru_cache(maxsize=200_000)
103
+ def _lcs_similarity_cached(s1: str, s2: str) -> float:
104
+ """Ratcliff/Obershelp similarity of two strings, memoized.
105
+
106
+ This is the numeric core of ``_lcs_similarity`` — kept byte-for-byte
107
+ identical to the reference implementation (``difflib.SequenceMatcher``'s
108
+ matching-block sum, i.e. ``2*|matches| / (|s1| + |s2|)``), so scores are
109
+ unchanged. It is factored out purely so ``factored_2dmss`` — which calls
110
+ this ``O(R1*C1*R2*C2)`` times per table pair, mostly on *repeated* cell
111
+ strings (empty cells, spanned headers, duplicated values) — pays the
112
+ ``SequenceMatcher`` cost only once per distinct ``(s1, s2)`` pair instead
113
+ of once per grid position. Pure function ⇒ the cache is always exact.
114
+
115
+ Measured (``scripts/bench_table_metrics.py``, GriTS-Con, random tables)::
116
+
117
+ size difflib ms memoized ms speedup |Δ score|
118
+ 8x5 4.3 1.5 2.9x 0.00e+00
119
+ 15x8 38.5 12.7 3.0x 0.00e+00
120
+ 25x10 168.7 56.2 3.0x 0.00e+00
121
+ 40x12 644.7 243.6 2.6x 0.00e+00
122
+ """
123
+ if len(s1) == 0 and len(s2) == 0:
124
+ return 1.0
125
+ s = SequenceMatcher(None, s1, s2)
126
+ lcs = "".join([s1[block.a : (block.a + block.size)] for block in s.get_matching_blocks()])
127
+ return 2 * len(lcs) / (len(s1) + len(s2))
128
+
129
+
130
+ def _lcs_similarity(string1: Any, string2: Any) -> float:
131
+ """Compute longest-common-subsequence similarity between two strings.
132
+
133
+ Returns 2*|LCS| / (|s1| + |s2|), ranging from 0.0 (no overlap) to
134
+ 1.0 (identical strings). Returns 1.0 when both strings are empty.
135
+ Handles non-string grid values (e.g., scalar 0 for unoccupied cells).
136
+
137
+ Delegates to the memoized :func:`_lcs_similarity_cached` after coercing
138
+ non-string grid values to ``str`` exactly as before, so the result is
139
+ identical while avoiding recomputation for repeated cell pairs.
140
+ """
141
+ s1 = str(string1) if not isinstance(string1, str) else string1
142
+ s2 = str(string2) if not isinstance(string2, str) else string2
143
+ return _lcs_similarity_cached(s1, s2)
144
+
145
+
146
+ def _compute_fscore(num_true_positives: float, num_true: int, num_positives: int) -> tuple[float, float, float]:
147
+ """Compute F-score, precision, and recall.
148
+
149
+ Conventions (from the reference implementation):
150
+ - precision is 1 when there are no predicted instances
151
+ - recall is 1 when there are no true instances
152
+ - fscore is 0 when recall or precision is 0
153
+ """
154
+ precision = num_true_positives / num_positives if num_positives > 0 else 1.0
155
+ recall = num_true_positives / num_true if num_true > 0 else 1.0
156
+
157
+ if precision + recall > 0:
158
+ fscore = 2 * precision * recall / (precision + recall)
159
+ else:
160
+ fscore = 0.0
161
+
162
+ return fscore, precision, recall
163
+
164
+
165
+ def _initialize_dp(seq1_len: int, seq2_len: int) -> tuple[np.ndarray, np.ndarray]:
166
+ """Initialize dynamic programming score and pointer tables."""
167
+ scores = np.zeros((seq1_len + 1, seq2_len + 1))
168
+ pointers = np.zeros((seq1_len + 1, seq2_len + 1))
169
+
170
+ for i in range(1, seq1_len + 1):
171
+ pointers[i, 0] = -1 # up
172
+ for j in range(1, seq2_len + 1):
173
+ pointers[0, j] = 1 # left
174
+
175
+ return scores, pointers
176
+
177
+
178
+ def _traceback(pointers: np.ndarray) -> tuple[list[int], list[int]]:
179
+ """Traceback through DP pointer table to get aligned indices.
180
+
181
+ Convention: -1 = up, 1 = left, 0 = diagonal (match).
182
+ """
183
+ i = pointers.shape[0] - 1
184
+ j = pointers.shape[1] - 1
185
+ seq1_indices: list[int] = []
186
+ seq2_indices: list[int] = []
187
+
188
+ while not (i == 0 and j == 0):
189
+ if pointers[i, j] == -1:
190
+ i -= 1
191
+ elif pointers[i, j] == 1:
192
+ j -= 1
193
+ else:
194
+ i -= 1
195
+ j -= 1
196
+ seq1_indices.append(i)
197
+ seq2_indices.append(j)
198
+
199
+ return seq1_indices[::-1], seq2_indices[::-1]
200
+
201
+
202
+ def _align_1d(
203
+ sequence1: list[tuple[int, int]],
204
+ sequence2: list[tuple[int, int]],
205
+ reward_lookup: dict[tuple[int, int, int, int], float],
206
+ return_alignment: bool = False,
207
+ ) -> float | tuple[list[int], list[int], float]:
208
+ """1D sequence alignment with pre-computed rewards.
209
+
210
+ Sequences are index tuples into the reward lookup table.
211
+ """
212
+ seq1_len = len(sequence1)
213
+ seq2_len = len(sequence2)
214
+ scores, pointers = _initialize_dp(seq1_len, seq2_len)
215
+
216
+ for i in range(1, seq1_len + 1):
217
+ for j in range(1, seq2_len + 1):
218
+ reward = reward_lookup[sequence1[i - 1] + sequence2[j - 1]]
219
+ diag = scores[i - 1, j - 1] + reward
220
+ skip_seq2 = scores[i, j - 1]
221
+ skip_seq1 = scores[i - 1, j]
222
+
223
+ best = max(diag, skip_seq1, skip_seq2)
224
+ scores[i, j] = best
225
+ if diag == best:
226
+ pointers[i, j] = 0
227
+ elif skip_seq1 == best:
228
+ pointers[i, j] = -1
229
+ else:
230
+ pointers[i, j] = 1
231
+
232
+ score = float(scores[-1, -1])
233
+
234
+ if not return_alignment:
235
+ return score
236
+
237
+ seq1_indices, seq2_indices = _traceback(pointers)
238
+ return seq1_indices, seq2_indices, score
239
+
240
+
241
+ def _align_2d_outer(
242
+ true_shape: tuple[int, int],
243
+ pred_shape: tuple[int, int],
244
+ reward_lookup: dict[tuple[int, int, int, int], float],
245
+ ) -> tuple[list[int], list[int], float]:
246
+ """2D sequence-of-sequences alignment.
247
+
248
+ Aligns two outer sequences (rows) where match reward between entries
249
+ is their 1D column alignment score.
250
+ """
251
+ scores, pointers = _initialize_dp(true_shape[0], pred_shape[0])
252
+
253
+ for row_idx in range(1, true_shape[0] + 1):
254
+ for col_idx in range(1, pred_shape[0] + 1):
255
+ reward_result = _align_1d(
256
+ [(row_idx - 1, tcol) for tcol in range(true_shape[1])],
257
+ [(col_idx - 1, prow) for prow in range(pred_shape[1])],
258
+ reward_lookup,
259
+ )
260
+ assert isinstance(reward_result, float)
261
+ reward = reward_result
262
+ diag = scores[row_idx - 1, col_idx - 1] + reward
263
+ same_row = scores[row_idx, col_idx - 1]
264
+ same_col = scores[row_idx - 1, col_idx]
265
+
266
+ best = max(diag, same_col, same_row)
267
+ scores[row_idx, col_idx] = best
268
+ if diag == best:
269
+ pointers[row_idx, col_idx] = 0
270
+ elif same_col == best:
271
+ pointers[row_idx, col_idx] = -1
272
+ else:
273
+ pointers[row_idx, col_idx] = 1
274
+
275
+ score = float(scores[-1, -1])
276
+ true_indices, pred_indices = _traceback(pointers)
277
+ return true_indices, pred_indices, score
278
+
279
+
280
+ def _kernel_mode() -> str:
281
+ """Select the ``factored_2dmss`` implementation: ``array`` | ``memo`` | ``slow``.
282
+
283
+ ``array`` (default) builds the reward as a vectorized numpy tensor over a
284
+ unique-cell-value similarity matrix and runs the alignment DP on Python lists
285
+ — no per-pair LCS redundancy, no R1·C1·R2·C2 dict build, no numpy scalar
286
+ indexing. ``memo`` is the previous fast path (memoized dict build). ``slow`` is
287
+ the original dict path. All three return bit-identical scores; the non-array
288
+ modes exist only for before/after benchmarking.
289
+
290
+ ``BENCH_GRITS_KERNEL`` selects explicitly; the legacy boolean
291
+ ``BENCH_GRITS_FAST_KERNEL=0`` forces ``slow``.
292
+ """
293
+ mode = os.environ.get("BENCH_GRITS_KERNEL", "").strip().lower()
294
+ if mode in ("array", "memo", "slow"):
295
+ return mode
296
+ return "slow" if os.environ.get("BENCH_GRITS_FAST_KERNEL", "1") == "0" else "array"
297
+
298
+
299
+ def _precompute_rewards(
300
+ true_grid: np.ndarray,
301
+ pred_grid: np.ndarray,
302
+ reward_function: Any,
303
+ memoize: bool,
304
+ ) -> tuple[dict[tuple[int, int, int, int], float], dict[tuple[int, int, int, int], float]]:
305
+ """Original dict reward lookups over every cell pair (``slow`` / ``memo`` modes).
306
+
307
+ The reward depends only on the two cell VALUES, but the grids carry the same
308
+ string in many cells, so the naive ``range(R1·C1·R2·C2)`` loop calls
309
+ ``reward_function`` (an LCS over strings) up to 1.7M times per pair with massive
310
+ redundancy. ``memoize`` caches by ``(cell_value, cell_value)`` (a pure-function
311
+ cache → identical rewards). The ``array`` kernel avoids this loop entirely.
312
+ """
313
+ pre_computed: dict[tuple[int, int, int, int], float] = {}
314
+ transpose_rewards: dict[tuple[int, int, int, int], float] = {}
315
+ product = itertools.product(
316
+ range(true_grid.shape[0]),
317
+ range(true_grid.shape[1]),
318
+ range(pred_grid.shape[0]),
319
+ range(pred_grid.shape[1]),
320
+ )
321
+ if memoize:
322
+ cache: dict[tuple[Any, Any], float] = {}
323
+ for trow, tcol, prow, pcol in product:
324
+ value_key = (true_grid[trow, tcol], pred_grid[prow, pcol])
325
+ reward = cache.get(value_key)
326
+ if reward is None:
327
+ reward = reward_function(*value_key)
328
+ cache[value_key] = reward
329
+ pre_computed[(trow, tcol, prow, pcol)] = reward
330
+ transpose_rewards[(tcol, trow, pcol, prow)] = reward
331
+ else:
332
+ for trow, tcol, prow, pcol in product:
333
+ reward = reward_function(true_grid[trow, tcol], pred_grid[prow, pcol])
334
+ pre_computed[(trow, tcol, prow, pcol)] = reward
335
+ transpose_rewards[(tcol, trow, pcol, prow)] = reward
336
+ return pre_computed, transpose_rewards
337
+
338
+
339
+ def _reward_tensor(
340
+ true_grid: np.ndarray,
341
+ pred_grid: np.ndarray,
342
+ reward_function: Any,
343
+ ) -> np.ndarray:
344
+ """Reward tensor ``R[trow, tcol, prow, pcol] = reward(true[trow,tcol], pred[prow,pcol])``.
345
+
346
+ ``reward_function`` is evaluated once per *distinct* (true_value, pred_value)
347
+ pair over a unique-string similarity matrix, then broadcast-gathered into the
348
+ full R1·C1·R2·C2 tensor — no per-cell Python loop, no LCS redundancy. Values
349
+ are the same doubles the dict path would store, so downstream scores are
350
+ bit-identical.
351
+ """
352
+ true_uids: dict[Any, int] = {}
353
+ pred_uids: dict[Any, int] = {}
354
+ uid_true = np.empty(true_grid.shape, dtype=np.intp)
355
+ uid_pred = np.empty(pred_grid.shape, dtype=np.intp)
356
+ unique_true: list[Any] = []
357
+ unique_pred: list[Any] = []
358
+ for r in range(true_grid.shape[0]):
359
+ for c in range(true_grid.shape[1]):
360
+ value = true_grid[r, c]
361
+ uid = true_uids.get(value)
362
+ if uid is None:
363
+ uid = len(unique_true)
364
+ true_uids[value] = uid
365
+ unique_true.append(value)
366
+ uid_true[r, c] = uid
367
+ for r in range(pred_grid.shape[0]):
368
+ for c in range(pred_grid.shape[1]):
369
+ value = pred_grid[r, c]
370
+ uid = pred_uids.get(value)
371
+ if uid is None:
372
+ uid = len(unique_pred)
373
+ pred_uids[value] = uid
374
+ unique_pred.append(value)
375
+ uid_pred[r, c] = uid
376
+
377
+ sim = np.empty((len(unique_true), len(unique_pred)), dtype=np.float64)
378
+ for a, value_a in enumerate(unique_true):
379
+ for b, value_b in enumerate(unique_pred):
380
+ sim[a, b] = reward_function(value_a, value_b)
381
+
382
+ # Broadcast-gather: (R1,C1,1,1) x (1,1,R2,C2) -> (R1,C1,R2,C2)
383
+ return sim[uid_true[:, :, None, None], uid_pred[None, None, :, :]]
384
+
385
+
386
+ def _traceback_list(pointers: list[list[int]]) -> tuple[list[int], list[int]]:
387
+ """List-based traceback (mirror of :func:`_traceback`). -1=up, 1=left, 0=match."""
388
+ i = len(pointers) - 1
389
+ j = len(pointers[0]) - 1
390
+ seq1_indices: list[int] = []
391
+ seq2_indices: list[int] = []
392
+ while not (i == 0 and j == 0):
393
+ ptr = pointers[i][j]
394
+ if ptr == -1:
395
+ i -= 1
396
+ elif ptr == 1:
397
+ j -= 1
398
+ else:
399
+ i -= 1
400
+ j -= 1
401
+ seq1_indices.append(i)
402
+ seq2_indices.append(j)
403
+ return seq1_indices[::-1], seq2_indices[::-1]
404
+
405
+
406
+ @overload
407
+ def _align_matrix(reward_matrix: list[list[float]], return_alignment: Literal[False] = False) -> float: ...
408
+
409
+
410
+ @overload
411
+ def _align_matrix(
412
+ reward_matrix: list[list[float]], return_alignment: Literal[True]
413
+ ) -> tuple[list[int], list[int], float]: ...
414
+
415
+
416
+ def _align_matrix(
417
+ reward_matrix: list[list[float]],
418
+ return_alignment: bool = False,
419
+ ) -> float | tuple[list[int], list[int], float]:
420
+ """Max-reward monotonic alignment over a dense ``M×N`` reward matrix.
421
+
422
+ Identical DP and tie-breaking to :func:`_align_1d` / :func:`_align_2d_outer`
423
+ (``best = max(diag, up, left)``; tie prefers diag, then up, then left), but on
424
+ plain Python lists — no numpy scalar indexing, no 4-tuple dict keys.
425
+ """
426
+ m = len(reward_matrix)
427
+ n = len(reward_matrix[0]) if m else 0
428
+ scores = [[0.0] * (n + 1) for _ in range(m + 1)]
429
+ pointers = [[0] * (n + 1) for _ in range(m + 1)]
430
+ for i in range(1, m + 1):
431
+ pointers[i][0] = -1 # up
432
+ for j in range(1, n + 1):
433
+ pointers[0][j] = 1 # left
434
+
435
+ for i in range(1, m + 1):
436
+ reward_row = reward_matrix[i - 1]
437
+ scores_prev = scores[i - 1]
438
+ scores_cur = scores[i]
439
+ pointers_cur = pointers[i]
440
+ for j in range(1, n + 1):
441
+ diag = scores_prev[j - 1] + reward_row[j - 1]
442
+ skip_up = scores_prev[j]
443
+ skip_left = scores_cur[j - 1]
444
+ best = max(diag, skip_up, skip_left)
445
+ scores_cur[j] = best
446
+ if diag == best:
447
+ pointers_cur[j] = 0
448
+ elif skip_up == best:
449
+ pointers_cur[j] = -1
450
+ else:
451
+ pointers_cur[j] = 1
452
+
453
+ score = scores[m][n]
454
+ if not return_alignment:
455
+ return score
456
+ seq1_indices, seq2_indices = _traceback_list(pointers)
457
+ return seq1_indices, seq2_indices, score
458
+
459
+
460
+ def _factored_2dmss_array(
461
+ true_grid: np.ndarray,
462
+ pred_grid: np.ndarray,
463
+ reward_function: Any,
464
+ ) -> tuple[float, float, float, float, dict[int, int], dict[int, int]]:
465
+ """Array-kernel ``factored_2dmss`` — bit-identical to the dict path.
466
+
467
+ Builds the reward tensor once (vectorized), then does the same factored
468
+ row/column alignments. The per-row-pair column score and per-col-pair row
469
+ score matrices (``RR`` / ``CC``) are exactly the inner ``_align_1d`` scores the
470
+ dict path recomputes inside ``_align_2d_outer``. ``positive_match`` is summed in
471
+ the original nested order so floating-point rounding matches to the last bit.
472
+ """
473
+ reward = _reward_tensor(true_grid, pred_grid, reward_function)
474
+ n_true_rows, n_true_cols, n_pred_rows, n_pred_cols = reward.shape
475
+
476
+ # Row alignment: reward between true row i and pred row j is their column
477
+ # alignment score over reward[i, :, j, :] (C1×C2).
478
+ rr = [[_align_matrix(reward[i, :, j, :].tolist()) for j in range(n_pred_rows)] for i in range(n_true_rows)]
479
+ true_row_nums, pred_row_nums, row_score = _align_matrix(rr, return_alignment=True)
480
+
481
+ # Column alignment: reward between true col i and pred col j is their row
482
+ # alignment score over reward[:, i, :, j] (R1×R2).
483
+ cc = [[_align_matrix(reward[:, i, :, j].tolist()) for j in range(n_pred_cols)] for i in range(n_true_cols)]
484
+ true_col_nums, pred_col_nums, col_score = _align_matrix(cc, return_alignment=True)
485
+
486
+ num_true = n_true_rows * n_true_cols
487
+ num_pos = n_pred_rows * n_pred_cols
488
+
489
+ upper_bound = min(row_score, col_score)
490
+ ub_fscore, _, _ = _compute_fscore(upper_bound, num_true, num_pos)
491
+
492
+ # Sum matched-cell rewards in the same (row-pair outer, col-pair inner) order
493
+ # as the dict path so the float reduction is bit-identical.
494
+ positive_match = 0.0
495
+ for true_row, pred_row in zip(true_row_nums, pred_row_nums, strict=True):
496
+ for true_col, pred_col in zip(true_col_nums, pred_col_nums, strict=True):
497
+ positive_match += float(reward[true_row, true_col, pred_row, pred_col])
498
+
499
+ fscore, precision, recall = _compute_fscore(positive_match, num_true, num_pos)
500
+ row_map = dict(zip(true_row_nums, pred_row_nums, strict=True))
501
+ col_map = dict(zip(true_col_nums, pred_col_nums, strict=True))
502
+ return fscore, precision, recall, ub_fscore, row_map, col_map
503
+
504
+
505
+ def factored_2dmss(
506
+ true_grid: np.ndarray,
507
+ pred_grid: np.ndarray,
508
+ reward_function: Any,
509
+ ) -> tuple[float, float, float, float]:
510
+ """Factored 2D most-similar substructures (2D-MSS).
511
+
512
+ A polynomial-time heuristic for the NP-hard 2D-MSS problem. Finds
513
+ the substructures of two matrices with the greatest total similarity.
514
+
515
+ Returns (fscore, precision, recall, upper_bound_score).
516
+ """
517
+ mode = _kernel_mode()
518
+ if mode == "array":
519
+ fscore, precision, recall, ub_fscore, _, _ = _factored_2dmss_array(true_grid, pred_grid, reward_function)
520
+ return fscore, precision, recall, ub_fscore
521
+
522
+ pre_computed, transpose_rewards = _precompute_rewards(
523
+ true_grid, pred_grid, reward_function, memoize=(mode == "memo")
524
+ )
525
+
526
+ num_pos = pred_grid.shape[0] * pred_grid.shape[1]
527
+ num_true = true_grid.shape[0] * true_grid.shape[1]
528
+
529
+ true_row_nums, pred_row_nums, row_score = _align_2d_outer(true_grid.shape[:2], pred_grid.shape[:2], pre_computed)
530
+
531
+ true_col_nums, pred_col_nums, col_score = _align_2d_outer(
532
+ true_grid.shape[:2][::-1],
533
+ pred_grid.shape[:2][::-1],
534
+ transpose_rewards,
535
+ )
536
+
537
+ upper_bound = min(row_score, col_score)
538
+ ub_fscore, _, _ = _compute_fscore(upper_bound, num_true, num_pos)
539
+
540
+ positive_match = 0.0
541
+ for true_row, pred_row in zip(true_row_nums, pred_row_nums, strict=False):
542
+ for true_col, pred_col in zip(true_col_nums, pred_col_nums, strict=False):
543
+ positive_match += pre_computed[(true_row, true_col, pred_row, pred_col)]
544
+
545
+ fscore, precision, recall = _compute_fscore(positive_match, num_true, num_pos)
546
+ return fscore, precision, recall, ub_fscore
547
+
548
+
549
+ def factored_2dmss_with_alignment(
550
+ true_grid: np.ndarray,
551
+ pred_grid: np.ndarray,
552
+ reward_function: Any,
553
+ ) -> tuple[float, float, float, float, dict[int, int], dict[int, int]]:
554
+ """Like factored_2dmss, but also returns row and column alignment maps.
555
+
556
+ Returns (fscore, precision, recall, upper_bound, row_map, col_map)
557
+ where row_map = {true_row: pred_row} and col_map = {true_col: pred_col}.
558
+ """
559
+ mode = _kernel_mode()
560
+ if mode == "array":
561
+ return _factored_2dmss_array(true_grid, pred_grid, reward_function)
562
+
563
+ pre_computed, transpose_rewards = _precompute_rewards(
564
+ true_grid, pred_grid, reward_function, memoize=(mode == "memo")
565
+ )
566
+
567
+ num_pos = pred_grid.shape[0] * pred_grid.shape[1]
568
+ num_true = true_grid.shape[0] * true_grid.shape[1]
569
+
570
+ true_row_nums, pred_row_nums, row_score = _align_2d_outer(true_grid.shape[:2], pred_grid.shape[:2], pre_computed)
571
+
572
+ true_col_nums, pred_col_nums, col_score = _align_2d_outer(
573
+ true_grid.shape[:2][::-1],
574
+ pred_grid.shape[:2][::-1],
575
+ transpose_rewards,
576
+ )
577
+
578
+ row_map = dict(zip(true_row_nums, pred_row_nums, strict=True))
579
+ col_map = dict(zip(true_col_nums, pred_col_nums, strict=True))
580
+
581
+ upper_bound = min(row_score, col_score)
582
+ ub_fscore, _, _ = _compute_fscore(upper_bound, num_true, num_pos)
583
+
584
+ positive_match = 0.0
585
+ for true_row, pred_row in zip(true_row_nums, pred_row_nums, strict=False):
586
+ for true_col, pred_col in zip(true_col_nums, pred_col_nums, strict=False):
587
+ positive_match += pre_computed[(true_row, true_col, pred_row, pred_col)]
588
+
589
+ fscore, precision, recall = _compute_fscore(positive_match, num_true, num_pos)
590
+ return fscore, precision, recall, ub_fscore, row_map, col_map
591
+
592
+
593
+ # =============================================================================
594
+ # HTML table parsing
595
+ # =============================================================================
596
+
597
+
598
+ def html_to_cells(table_html: str) -> list[dict[str, Any]] | None:
599
+ """Parse an HTML table string into a list of cell dictionaries.
600
+
601
+ Each cell dict has keys: row_nums, column_nums, is_column_header, cell_text.
602
+ Returns None if parsing fails.
603
+ """
604
+ try:
605
+ parser = html.HTMLParser(remove_comments=True, encoding="utf-8")
606
+ doc = html.fromstring(table_html, parser=parser)
607
+ except Exception:
608
+ return None
609
+
610
+ # Find the <table> element (lxml may wrap in <html><body>)
611
+ if doc.tag == "table":
612
+ tree = doc
613
+ else:
614
+ tables = doc.xpath(".//table")
615
+ if not tables:
616
+ return None
617
+ tree = tables[0]
618
+
619
+ table_cells: list[dict[str, Any]] = []
620
+ occupied_columns_by_row: dict[int, set[int]] = defaultdict(set)
621
+ current_row = -1
622
+
623
+ stack: list[tuple[Any, bool]] = [(tree, False)]
624
+ while stack:
625
+ current, in_header = stack.pop()
626
+
627
+ if current.tag == "tr":
628
+ current_row += 1
629
+
630
+ if current.tag in ("td", "th"):
631
+ colspan = int(current.attrib.get("colspan", "1"))
632
+ rowspan = int(current.attrib.get("rowspan", "1"))
633
+ row_nums = list(range(current_row, current_row + rowspan))
634
+
635
+ occupied = occupied_columns_by_row[current_row]
636
+ if occupied:
637
+ max_occ = max(occupied)
638
+ current_column = min(set(range(max_occ + 2)).difference(occupied))
639
+ else:
640
+ current_column = 0
641
+
642
+ column_nums = list(range(current_column, current_column + colspan))
643
+ for rn in row_nums:
644
+ occupied_columns_by_row[rn].update(column_nums)
645
+
646
+ # Convert <sup>/<sub> digit content to Unicode equivalents
647
+ # so that "Name<sup>1</sup>" becomes "Name¹", matching sources
648
+ # that already use Unicode superscripts.
649
+ _map = {"sup": _ASCII_TO_SUPERSCRIPT, "sub": _ASCII_TO_SUBSCRIPT}
650
+ for sup_sub in current.xpath(".//sup | .//sub"):
651
+ char_map = _map[sup_sub.tag]
652
+ converted = "".join(char_map.get(c, "") for c in (sup_sub.text or ""))
653
+ # Replace element with converted text in parent's tree
654
+ prev = sup_sub.getprevious()
655
+ if prev is not None:
656
+ prev.tail = (prev.tail or "") + converted + (sup_sub.tail or "")
657
+ else:
658
+ sup_sub.getparent().text = (sup_sub.getparent().text or "") + converted + (sup_sub.tail or "")
659
+ sup_sub.getparent().remove(sup_sub)
660
+ # Gather text from element and all descendants
661
+ cell_text = normalize_cell_text(" ".join(current.itertext()))
662
+
663
+ table_cells.append(
664
+ {
665
+ "row_nums": row_nums,
666
+ "column_nums": column_nums,
667
+ "is_column_header": current.tag == "th" or in_header,
668
+ "cell_text": cell_text,
669
+ }
670
+ )
671
+
672
+ children = list(current)
673
+ for child in children[::-1]:
674
+ stack.append((child, in_header or current.tag in ("th", "thead")))
675
+
676
+ return table_cells
677
+
678
+
679
+ def cells_to_grid(cells: list[dict[str, Any]], key: str = "cell_text") -> list[list[Any]]:
680
+ """Convert cell list to a 2D grid keyed by 'cell_text' or 'bbox'.
681
+
682
+ For GriTS_Con, use key='cell_text'.
683
+ """
684
+ if not cells:
685
+ return [[]]
686
+ num_rows = max(max(c["row_nums"]) for c in cells) + 1
687
+ num_cols = max(max(c["column_nums"]) for c in cells) + 1
688
+ grid: list[list[Any]] = [[0] * num_cols for _ in range(num_rows)]
689
+ for cell in cells:
690
+ for rn in cell["row_nums"]:
691
+ for cn in cell["column_nums"]:
692
+ grid[rn][cn] = cell[key]
693
+ return grid
694
+
695
+
696
+ # =============================================================================
697
+ # High-level GriTS computation from HTML
698
+ # =============================================================================
699
+
700
+
701
+ def grits_con(true_text_grid: np.ndarray, pred_text_grid: np.ndarray) -> tuple[float, float, float, float]:
702
+ """Compute GriTS_Con (content) from text grids."""
703
+ return factored_2dmss(true_text_grid, pred_text_grid, _lcs_similarity)
704
+
705
+
706
+ def grits_con_with_alignment(
707
+ true_text_grid: np.ndarray, pred_text_grid: np.ndarray
708
+ ) -> tuple[float, float, float, float, dict[int, int], dict[int, int]]:
709
+ """GriTS_Con that also returns row/col alignment maps."""
710
+ return factored_2dmss_with_alignment(true_text_grid, pred_text_grid, _lcs_similarity)
711
+
712
+
713
+ def grits_from_html(
714
+ true_html: str,
715
+ pred_html: str,
716
+ min_cells_for_mismatch_skip: int = DEFAULT_MIN_CELLS_FOR_MISMATCH_SKIP,
717
+ max_dimension_ratio: float = DEFAULT_MAX_DIMENSION_RATIO,
718
+ mismatch_skip_score: float = DEFAULT_MISMATCH_SKIP_SCORE,
719
+ ) -> dict[str, Any] | None:
720
+ """Compute GriTS_Con from two HTML table strings.
721
+
722
+ Args:
723
+ true_html: Ground-truth HTML table string.
724
+ pred_html: Predicted HTML table string.
725
+
726
+ Returns a dict with keys: grits_con and its precision/recall/upper_bound
727
+ variants, plus alignment maps. Returns None if parsing fails.
728
+ """
729
+ true_cells = html_to_cells(true_html)
730
+ pred_cells = html_to_cells(pred_html)
731
+
732
+ if true_cells is None or pred_cells is None:
733
+ return None
734
+ if not true_cells or not pred_cells:
735
+ return None
736
+
737
+ true_text = np.array(cells_to_grid(true_cells, key="cell_text"), dtype=object)
738
+ pred_text = np.array(cells_to_grid(pred_cells, key="cell_text"), dtype=object)
739
+
740
+ true_rows = max(max(c["row_nums"]) for c in true_cells) + 1
741
+ true_cols = max(max(c["column_nums"]) for c in true_cells) + 1
742
+ pred_rows = max(max(c["row_nums"]) for c in pred_cells) + 1
743
+ pred_cols = max(max(c["column_nums"]) for c in pred_cells) + 1
744
+
745
+ true_cells_count = true_rows * true_cols
746
+ pred_cells_count = pred_rows * pred_cols
747
+
748
+ # Skip when tables are large and dimensions are badly mismatched —
749
+ # the prediction is structurally wrong so GriTS won't be informative,
750
+ # and the O(R1*C1*R2*C2) cost would be extreme.
751
+ larger_cells = max(true_cells_count, pred_cells_count)
752
+ if larger_cells >= min_cells_for_mismatch_skip:
753
+ row_ratio = max(true_rows, pred_rows) / max(min(true_rows, pred_rows), 1)
754
+ col_ratio = max(true_cols, pred_cols) / max(min(true_cols, pred_cols), 1)
755
+ if row_ratio > max_dimension_ratio or col_ratio > max_dimension_ratio:
756
+ print(
757
+ f" GriTS: skipping — large table ({true_rows}x{true_cols} vs "
758
+ f"{pred_rows}x{pred_cols}) with dimension ratio "
759
+ f"{max(row_ratio, col_ratio):.1f}x > {max_dimension_ratio}x threshold, "
760
+ f"scoring {mismatch_skip_score}",
761
+ flush=True,
762
+ )
763
+ s = mismatch_skip_score
764
+ return {
765
+ "grits_con": s,
766
+ "grits_precision_con": s,
767
+ "grits_recall_con": s,
768
+ "grits_con_upper_bound": s,
769
+ "_con_row_alignment": {},
770
+ "_con_col_alignment": {},
771
+ }
772
+
773
+ metrics: dict[str, Any] = {}
774
+ (
775
+ metrics["grits_con"],
776
+ metrics["grits_precision_con"],
777
+ metrics["grits_recall_con"],
778
+ metrics["grits_con_upper_bound"],
779
+ row_map,
780
+ col_map,
781
+ ) = grits_con_with_alignment(true_text, pred_text)
782
+ metrics["_con_row_alignment"] = row_map
783
+ metrics["_con_col_alignment"] = col_map
784
+
785
+ return metrics
786
+
787
+
788
+ def _leading_title_band_text(td: TableData) -> str | None:
789
+ """Normalized text of a leading full-width title band, if the table has one.
790
+
791
+ A title band is row 0 rendered as one colspan cell: after span expansion
792
+ every column of row 0 carries the same non-empty text. Tables with only
793
+ one row, or only one column, are excluded — the first has no body left to
794
+ score once the band is dropped, the second cannot distinguish a band from
795
+ an ordinary cell.
796
+ """
797
+ n_rows, n_cols = td.data.shape
798
+ if n_rows < 2 or n_cols < 2:
799
+ return None
800
+ texts = {normalize_cell_text(str(td.data[0, c])) for c in range(n_cols)}
801
+ if len(texts) != 1:
802
+ return None
803
+ text = texts.pop()
804
+ return text or None
805
+
806
+
807
+ def _drop_caption_matched_title_band(td: TableData, other_caption: str) -> TableData:
808
+ """Drop ``td``'s leading title band when ``other_caption`` says the same thing.
809
+
810
+ ``<caption>`` is the semantically correct place for a table's title, and a
811
+ prediction that uses it must not be scored as having lost the title row
812
+ that a ground truth spells as a full-width header band (or vice versa).
813
+ The caption never enters either grid — it is not a ``<td>``/``<th>`` — so
814
+ the band on the other side is the only asymmetry, and removing it makes
815
+ the two structures comparable.
816
+
817
+ Guarded by text equality under the ordinary cell normalization, so a band
818
+ that says something the caption does not is left in place and still
819
+ scores. Applied in both directions by the caller, so the treatment is
820
+ symmetric between ground truth and prediction.
821
+ """
822
+ caption = normalize_cell_text(other_caption)
823
+ if not caption:
824
+ return td
825
+ band = _leading_title_band_text(td)
826
+ if band is None or band != caption:
827
+ return td
828
+ return replace(td, data=td.data[1:, :])
829
+
830
+
831
+ def grits_con_from_table_data(
832
+ gt_td: TableData,
833
+ pred_td: TableData,
834
+ min_cells_for_mismatch_skip: int = DEFAULT_MIN_CELLS_FOR_MISMATCH_SKIP,
835
+ max_dimension_ratio: float = DEFAULT_MAX_DIMENSION_RATIO,
836
+ mismatch_skip_score: float = DEFAULT_MISMATCH_SKIP_SCORE,
837
+ ) -> dict[str, Any] | None:
838
+ """Compute GriTS_Con from two parsed ``TableData`` objects.
839
+
840
+ Reads the resolved 2D grid from ``td.data`` directly (no HTML re-parsing)
841
+ and applies the upgraded ``normalize_cell_text``. P5 entry point — replaces
842
+ the older ``grits_from_html`` path on the GriTS hot path.
843
+ """
844
+ if gt_td.data.size == 0 or pred_td.data.size == 0:
845
+ return None
846
+
847
+ # A <caption> on one side and a full-width title band saying the same
848
+ # thing on the other are the same title rendered two ways; neutralize the
849
+ # band so the difference costs nothing. Both directions, so the treatment
850
+ # is symmetric.
851
+ if pred_td.caption:
852
+ gt_td = _drop_caption_matched_title_band(gt_td, pred_td.caption)
853
+ if gt_td.caption:
854
+ pred_td = _drop_caption_matched_title_band(pred_td, gt_td.caption)
855
+ if gt_td.data.size == 0 or pred_td.data.size == 0:
856
+ return None
857
+
858
+ true_rows, true_cols = gt_td.data.shape
859
+ pred_rows, pred_cols = pred_td.data.shape
860
+
861
+ # Skip when tables are large and dimensions are badly mismatched —
862
+ # the prediction is structurally wrong so GriTS won't be informative,
863
+ # and the O(R1*C1*R2*C2) cost would be extreme.
864
+ larger_cells = max(true_rows * true_cols, pred_rows * pred_cols)
865
+ if larger_cells >= min_cells_for_mismatch_skip:
866
+ row_ratio = max(true_rows, pred_rows) / max(min(true_rows, pred_rows), 1)
867
+ col_ratio = max(true_cols, pred_cols) / max(min(true_cols, pred_cols), 1)
868
+ if row_ratio > max_dimension_ratio or col_ratio > max_dimension_ratio:
869
+ print(
870
+ f" GriTS: skipping — large table ({true_rows}x{true_cols} vs "
871
+ f"{pred_rows}x{pred_cols}) with dimension ratio "
872
+ f"{max(row_ratio, col_ratio):.1f}x > {max_dimension_ratio}x threshold, "
873
+ f"scoring {mismatch_skip_score}",
874
+ flush=True,
875
+ )
876
+ s = mismatch_skip_score
877
+ return {
878
+ "grits_con": s,
879
+ "grits_precision_con": s,
880
+ "grits_recall_con": s,
881
+ "grits_con_upper_bound": s,
882
+ "_con_row_alignment": {},
883
+ "_con_col_alignment": {},
884
+ }
885
+
886
+ true_text = np.empty_like(gt_td.data)
887
+ for r in range(true_rows):
888
+ for c in range(true_cols):
889
+ true_text[r, c] = normalize_cell_text(str(gt_td.data[r, c]))
890
+ pred_text = np.empty_like(pred_td.data)
891
+ for r in range(pred_rows):
892
+ for c in range(pred_cols):
893
+ pred_text[r, c] = normalize_cell_text(str(pred_td.data[r, c]))
894
+
895
+ metrics: dict[str, Any] = {}
896
+ (
897
+ metrics["grits_con"],
898
+ metrics["grits_precision_con"],
899
+ metrics["grits_recall_con"],
900
+ metrics["grits_con_upper_bound"],
901
+ row_map,
902
+ col_map,
903
+ ) = grits_con_with_alignment(true_text, pred_text)
904
+ metrics["_con_row_alignment"] = row_map
905
+ metrics["_con_col_alignment"] = col_map
906
+
907
+ return metrics
908
+
909
+
910
+ # =============================================================================
911
+ # Module-level helper for parallel pairwise computation
912
+ # (must be top-level so ProcessPoolExecutor can pickle it)
913
+ # =============================================================================
914
+
915
+ _ZERO_RESULT: dict[str, Any] = {
916
+ "grits_con": 0.0,
917
+ "grits_precision_con": 0.0,
918
+ "grits_recall_con": 0.0,
919
+ "grits_con_upper_bound": 0.0,
920
+ "_con_row_alignment": {},
921
+ "_con_col_alignment": {},
922
+ }
923
+
924
+
925
+ # =============================================================================
926
+ # GriTSMetric class (Metric interface)
927
+ # =============================================================================
928
+
929
+
930
+ class GriTSMetric(Metric):
931
+ """GriTS metric for comparing HTML tables in markdown content.
932
+
933
+ Computes Grid Table Similarity (content / GriTS_Con) between expected
934
+ and actual HTML tables. Uses the Hungarian algorithm for optimal table
935
+ matching when documents contain multiple tables.
936
+ """
937
+
938
+ @property
939
+ def name(self) -> str:
940
+ """Return the name of this metric."""
941
+ return "grits"
942
+
943
+ def compute( # type: ignore[override]
944
+ self,
945
+ expected_tables: list[Any],
946
+ actual_tables: list[Any],
947
+ *,
948
+ expected_pages: list[int] | None = None,
949
+ actual_pages: list[int] | None = None,
950
+ **kwargs: Any,
951
+ ) -> list[MetricValue]:
952
+ """Compute GriTS_Con scores between expected and actual table sets.
953
+
954
+ Consumes pre-extracted ``ExtractedTable`` lists from the shared
955
+ ``extract_table_pairs`` stage so that GriTS and TRM provably see
956
+ the same tables. The lift is purely "stop calling extract_html_tables
957
+ yourself" — internal scoring (html_to_cells → cells_to_grid → grits_con,
958
+ Hungarian assignment) is unchanged from main.
959
+
960
+ Args:
961
+ expected_tables: Pre-extracted GT tables (``list[ExtractedTable]``).
962
+ actual_tables: Pre-extracted predicted tables (``list[ExtractedTable]``).
963
+ expected_pages: Optional page number (1-indexed) per GT table, aligned
964
+ with ``expected_tables``. When supplied together with
965
+ ``actual_pages`` (and length-consistent), the GT->pred assignment
966
+ is solved per page so a GT table only pairs with a prediction on
967
+ the same page. Omit for the document-global assignment.
968
+ actual_pages: Optional page number per predicted table, aligned with
969
+ ``actual_tables``.
970
+ kwargs: Additional parameters (not used)
971
+
972
+ Returns:
973
+ List with a single MetricValue for grits_con.
974
+ """
975
+ # P5: read TableData directly from the ExtractedTable inputs and
976
+ # apply the upgraded normalize_cell_text. The raw_html field is no
977
+ # longer touched on the GriTS hot path.
978
+ expected_td = [et.table_data for et in expected_tables]
979
+ actual_td = [et.table_data for et in actual_tables]
980
+
981
+ shared_meta: dict[str, Any] = {}
982
+
983
+ if not expected_td:
984
+ shared_meta = {
985
+ "note": "No tables found in expected markdown",
986
+ "tables_found_expected": 0,
987
+ "tables_found_actual": len(actual_td),
988
+ "pairing": [],
989
+ }
990
+ return [MetricValue(metric_name="grits_con", value=0.0, metadata=shared_meta)]
991
+
992
+ if not actual_td:
993
+ shared_meta = {
994
+ "note": "No tables found in actual markdown",
995
+ "tables_found_expected": len(expected_td),
996
+ "tables_found_actual": 0,
997
+ "tables_matched": 0,
998
+ "pairing": [(i, None) for i in range(len(expected_td))],
999
+ }
1000
+ return [MetricValue(metric_name="grits_con", value=0.0, metadata=shared_meta)]
1001
+
1002
+ n_expected = len(expected_td)
1003
+ n_actual = len(actual_td)
1004
+
1005
+ # When per-table page labels are supplied, only same-page pairs are
1006
+ # eligible to match — so only those need a GriTS comparison. This both
1007
+ # constrains the assignment to within-page pairs and avoids the O(n_gt *
1008
+ # n_pred) blow-up on multi-page documents.
1009
+ blocked = page_blocking_active(expected_pages, actual_pages, n_expected, n_actual)
1010
+ allowed_pairs = [
1011
+ (i, j)
1012
+ for i in range(n_expected)
1013
+ for j in range(n_actual)
1014
+ if not blocked or expected_pages[i] == actual_pages[j] # type: ignore[index]
1015
+ ]
1016
+ total_pairs = len(allowed_pairs)
1017
+
1018
+ print(
1019
+ f" GriTS: comparing {n_expected} expected x {n_actual} actual = {total_pairs} table pair(s)"
1020
+ f"{' (same-page only)' if blocked else ''}",
1021
+ flush=True,
1022
+ )
1023
+
1024
+ # Compute pairwise GriTS scores for the eligible pairs only. Disallowed
1025
+ # (cross-page) cells keep cost 0.0 and are never read — the per-page
1026
+ # assignment only ever selects same-page pairs.
1027
+ results_cache: dict[tuple[int, int], dict[str, Any]] = {}
1028
+ cost_matrix = np.zeros((n_expected, n_actual))
1029
+
1030
+ for pair_idx, (i, j) in enumerate(allowed_pairs, start=1):
1031
+ if total_pairs > 1:
1032
+ print(f" GriTS: table pair {pair_idx}/{total_pairs}", flush=True)
1033
+ maybe_result = grits_con_from_table_data(expected_td[i], actual_td[j])
1034
+ result = maybe_result if maybe_result is not None else dict(_ZERO_RESULT)
1035
+ results_cache[(i, j)] = result
1036
+ cost_matrix[i, j] = -result["grits_con"]
1037
+
1038
+ # Solve assignment (per page when page labels are supplied, else global).
1039
+ row_ind, col_ind = assign_tables(cost_matrix, expected_pages, actual_pages)
1040
+
1041
+ per_table_details: list[dict[str, Any]] = []
1042
+ con_scores: list[float] = []
1043
+ matched_gt: set[int] = set()
1044
+
1045
+ for gt_idx, pred_idx in zip(row_ind, col_ind, strict=True):
1046
+ gi, pi = int(gt_idx), int(pred_idx)
1047
+ result = results_cache[(gi, pi)]
1048
+ con_scores.append(result["grits_con"])
1049
+ detail: dict[str, Any] = {
1050
+ "gt_table_index": gi,
1051
+ "pred_table_index": pi,
1052
+ "grits_con": result["grits_con"],
1053
+ "grits_precision_con": result["grits_precision_con"],
1054
+ "grits_recall_con": result["grits_recall_con"],
1055
+ "_con_row_alignment": result.get("_con_row_alignment", {}),
1056
+ "_con_col_alignment": result.get("_con_col_alignment", {}),
1057
+ }
1058
+ if blocked:
1059
+ detail["gt_page"] = expected_pages[gi] # type: ignore[index]
1060
+ detail["pred_page"] = actual_pages[pi] # type: ignore[index]
1061
+ per_table_details.append(detail)
1062
+ matched_gt.add(gi)
1063
+
1064
+ # Unmatched expected tables score 0
1065
+ for i in range(n_expected):
1066
+ if i not in matched_gt:
1067
+ con_scores.append(0.0)
1068
+ unmatched_detail: dict[str, Any] = {
1069
+ "gt_table_index": i,
1070
+ "pred_table_index": None,
1071
+ "grits_con": 0.0,
1072
+ "note": "No matching table in actual",
1073
+ }
1074
+ if blocked:
1075
+ unmatched_detail["gt_page"] = expected_pages[i] # type: ignore[index]
1076
+ per_table_details.append(unmatched_detail)
1077
+
1078
+ avg_con = sum(con_scores) / len(con_scores) if con_scores else 0.0
1079
+ print(f" GriTS: done, con = {avg_con:.4f}", flush=True)
1080
+
1081
+ # Build the load-bearing pairing key consumed by TRM and the
1082
+ # evaluator's count metrics: list[(gt_idx, pred_idx | None)] of
1083
+ # length n_expected. Unmatched GT tables get None.
1084
+ pairing: list[tuple[int, int | None]] = []
1085
+ for i in range(n_expected):
1086
+ if i in matched_gt:
1087
+ # Find the matched pred index from row_ind/col_ind
1088
+ for gi, pi in zip(row_ind, col_ind, strict=True):
1089
+ if int(gi) == i:
1090
+ pairing.append((i, int(pi)))
1091
+ break
1092
+ else:
1093
+ pairing.append((i, None))
1094
+
1095
+ shared_meta = {
1096
+ "tables_found_expected": n_expected,
1097
+ "tables_found_actual": n_actual,
1098
+ "tables_matched": len(row_ind),
1099
+ "per_table_details": per_table_details,
1100
+ "pairing": pairing,
1101
+ }
1102
+
1103
+ # Build human-readable detail strings
1104
+ details: list[str] = []
1105
+ details.append(f"{n_expected} table(s) expected, {n_actual} found, {len(row_ind)} matched")
1106
+ for td in per_table_details:
1107
+ gt_i: int = td["gt_table_index"]
1108
+ pr_i: int | None = td.get("pred_table_index")
1109
+ if pr_i is None:
1110
+ details.append(f"Table {gt_i + 1}: no match found in prediction")
1111
+ else:
1112
+ details.append(
1113
+ f"Table {gt_i + 1}: con={td['grits_con']:.3f}"
1114
+ f" (precision={td.get('grits_precision_con', 0):.2f},"
1115
+ f" recall={td.get('grits_recall_con', 0):.2f})"
1116
+ )
1117
+
1118
+ return [
1119
+ MetricValue(
1120
+ metric_name="grits_con",
1121
+ value=avg_con,
1122
+ metadata=shared_meta,
1123
+ details=details,
1124
+ ),
1125
+ ]