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,1556 @@
1
+ """Chart data validation and rotation check test rules."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any, cast
6
+
7
+ import pandas as pd
8
+ from rapidfuzz import fuzz
9
+
10
+ from parse_bench.evaluation.metrics.parse.rules_base import (
11
+ CELL_FUZZY_MATCH_THRESHOLD,
12
+ ParseTestRule,
13
+ _dates_match,
14
+ _detect_csv_skip_rows,
15
+ )
16
+ from parse_bench.evaluation.metrics.parse.table_parsing import (
17
+ TableData,
18
+ parse_html_tables,
19
+ parse_markdown_tables,
20
+ )
21
+ from parse_bench.evaluation.metrics.parse.test_types import TestType
22
+ from parse_bench.evaluation.metrics.parse.utils import normalize_text
23
+ from parse_bench.schemas.parse_output import ParseOutput
24
+ from parse_bench.test_cases.parse_rule_schemas import (
25
+ ParseChartDataArrayDataRule,
26
+ ParseChartDataArrayLabelsRule,
27
+ ParseChartDataPointRule,
28
+ ParseRotateCheckRule,
29
+ )
30
+
31
+
32
+ def parse_chart_tables(content: str) -> list[TableData]:
33
+ """Parse the Markdown and HTML tables consumed by chart rules."""
34
+ return [*parse_markdown_tables(content), *parse_html_tables(content)]
35
+
36
+
37
+ # =============================================================================
38
+ # Number Normalization Utilities for Chart Tests
39
+ # =============================================================================
40
+
41
+
42
+ def normalize_number_string(s: str) -> float | None:
43
+ """
44
+ Convert various number formats to a normalized float.
45
+
46
+ Handles:
47
+ - Currency symbols: $, €, £, ¥
48
+ - Thousands separators: commas
49
+ - Suffixes: k, K, m, M, million, billion, b, B, etc.
50
+ - Percentage signs
51
+
52
+ Examples:
53
+ "39.2m" → 39.2
54
+ "$39.2M" → 39.2
55
+ "39,200,000" → 39200000.0
56
+ "1.5k" → 1.5
57
+ "45%" → 45.0
58
+
59
+ Returns None if string cannot be parsed as a number.
60
+ """
61
+ if not s:
62
+ return None
63
+
64
+ # Remove whitespace and normalize
65
+ s = s.strip()
66
+
67
+ # Remove currency symbols
68
+ s = re.sub(r"^[$€£¥]\s*", "", s)
69
+ s = re.sub(r"\s*[$€£¥]$", "", s)
70
+
71
+ # Remove approximate prefixes
72
+ s = re.sub(r"^[~≈]\s*", "", s)
73
+
74
+ # Remove percentage sign (but remember the value)
75
+ s = s.rstrip("%")
76
+
77
+ # Remove thousands separators (commas)
78
+ s = s.replace(",", "")
79
+
80
+ # Remove space-as-thousands-separator (e.g., "6 888" → "6888")
81
+ s = s.replace(" ", "")
82
+
83
+ # Handle suffixes — apply actual multipliers so values on different
84
+ # scales (e.g. "485k" vs "485567") can be compared numerically.
85
+ multiplier = 1.0
86
+ suffix_patterns = [
87
+ (r"(?i)\s*(trillion|trill|trn)$", 1e12),
88
+ (r"(?i)\s*(billion|bill|bln)$", 1e9),
89
+ (r"(?i)\s*(million|mill|mln)$", 1e6),
90
+ (r"(?i)\s*t$", 1e12),
91
+ (r"(?i)\s*g$", 1e9), # G = giga = billion
92
+ (r"(?i)\s*b$", 1e9),
93
+ (r"(?i)\s*m$", 1e6),
94
+ (r"(?i)\s*k$", 1e3),
95
+ ]
96
+
97
+ for pattern, mult in suffix_patterns:
98
+ if re.search(pattern, s):
99
+ s = re.sub(pattern, "", s)
100
+ multiplier = mult
101
+ break
102
+
103
+ # Try to parse as float
104
+ try:
105
+ return float(s) * multiplier
106
+ except ValueError:
107
+ return None
108
+
109
+
110
+ def _normalize_number_candidates(s: str) -> list[float]:
111
+ """Return all plausible numeric interpretations of *s*.
112
+
113
+ When *s* contains commas the comma is ambiguous: it could be a thousands
114
+ separator ("3,125" → 3125) or a French/European decimal separator
115
+ ("3,125" → 3.125). This helper returns both interpretations so callers
116
+ can try each.
117
+ """
118
+ candidates: list[float] = []
119
+ val = normalize_number_string(s)
120
+ if val is not None:
121
+ candidates.append(val)
122
+ # If s contains a comma, also try the decimal-separator interpretation.
123
+ if "," in s:
124
+ val2 = normalize_number_string(s.replace(",", "."))
125
+ if val2 is not None and val2 not in candidates:
126
+ candidates.append(val2)
127
+ return candidates
128
+
129
+
130
+ def numbers_match(val1: str, val2: str, tolerance: float = 0.01) -> bool:
131
+ """
132
+ Check if two number strings represent the same value.
133
+
134
+ Args:
135
+ val1: First value string
136
+ val2: Second value string
137
+ tolerance: Relative tolerance for comparison (default 1%)
138
+
139
+ Returns:
140
+ True if values match within tolerance, False otherwise
141
+ """
142
+ # Get all plausible numeric interpretations for each value.
143
+ # E.g. "2,08" produces [208.0, 2.08] (thousands-sep vs decimal-sep).
144
+ candidates1 = _normalize_number_candidates(val1)
145
+ candidates2 = _normalize_number_candidates(val2)
146
+
147
+ if not candidates1 or not candidates2:
148
+ return False
149
+
150
+ # Try every pair of interpretations — match if any combo agrees.
151
+ for num1 in candidates1:
152
+ for num2 in candidates2:
153
+ if num1 == 0 and num2 == 0:
154
+ return True
155
+ if num1 == 0 or num2 == 0:
156
+ if abs(num1 - num2) < tolerance:
157
+ return True
158
+ continue
159
+ relative_diff = abs(num1 - num2) / max(abs(num1), abs(num2))
160
+ if relative_diff <= tolerance:
161
+ return True
162
+
163
+ return False
164
+
165
+
166
+ def numeric_similarity(val1: str, val2: str) -> float | None:
167
+ """
168
+ Calculate similarity score between two numeric values using relative error.
169
+
170
+ Uses: score = max(0, 1 - |expected - actual| / |expected|)
171
+
172
+ This is equivalent to 1 - NRMSE for a single observation, providing
173
+ a statistically principled similarity measure.
174
+
175
+ References:
176
+ - Chai & Draxler (2014). RMSE or MAE? Geosci. Model Dev.
177
+ - Hyndman & Koehler (2006). Forecast accuracy. Int. J. Forecasting.
178
+
179
+ Returns:
180
+ Float between 0.0 and 1.0, or None if not both numbers.
181
+ - 0% error → 1.0
182
+ - 50% error → 0.5
183
+ - 100%+ error → 0.0
184
+ """
185
+ # Same comma-ambiguity handling as numbers_match: try all
186
+ # interpretations and return the best (highest) similarity score.
187
+ candidates1 = _normalize_number_candidates(val1) # expected
188
+ candidates2 = _normalize_number_candidates(val2) # actual
189
+
190
+ if not candidates1 or not candidates2:
191
+ return None
192
+
193
+ best: float | None = None
194
+ for num1 in candidates1:
195
+ for num2 in candidates2:
196
+ if num1 == 0 and num2 == 0:
197
+ return 1.0
198
+ if num1 == 0:
199
+ score = 1.0 if abs(num2) < 0.001 else 0.0
200
+ else:
201
+ relative_error = abs(num1 - num2) / abs(num1)
202
+ score = max(0.0, 1.0 - relative_error)
203
+ if best is None or score > best:
204
+ best = score
205
+
206
+ return best
207
+
208
+
209
+ def extract_numeric_parts(value: str) -> list[str]:
210
+ """Extract numeric parts from a composite value string.
211
+
212
+ Examples:
213
+ "25 (13.0%)" -> ["25", "13.0%"]
214
+ "25, 13.0%" -> ["25", "13.0%"]
215
+ "100/50" -> ["100", "50"]
216
+ "25" -> ["25"]
217
+ """
218
+ pattern = r"[-+]?\d(?:,\d{3}|\d)*\.?\d*%?"
219
+ return re.findall(pattern, value)
220
+
221
+
222
+ class ChartDataPointRule(ParseTestRule):
223
+ """
224
+ Test rule for chart-to-table conversions.
225
+
226
+ Verifies that a value is associated with given labels in a table,
227
+ regardless of whether the table is row-oriented or column-oriented.
228
+
229
+ This is useful for testing chart conversions where the same data
230
+ can be represented in multiple valid table orientations.
231
+ """
232
+
233
+ def __init__(self, rule_data: ParseChartDataPointRule | dict):
234
+ super().__init__(rule_data)
235
+ rule_data = cast(ParseChartDataPointRule, self._rule_data)
236
+
237
+ if self.type != TestType.CHART_DATA_POINT.value:
238
+ raise ValueError(f"Invalid type for ChartDataPointRule: {self.type}")
239
+
240
+ self.value = normalize_text(str(rule_data.value))
241
+ self.labels = [normalize_text(re.sub(r"<br\s*/?>", " ", str(label))) for label in rule_data.labels]
242
+ self.normalize_numbers = rule_data.normalize_numbers
243
+ self.relative_tolerance = rule_data.relative_tolerance
244
+
245
+ if not self.value:
246
+ raise ValueError("value field cannot be empty")
247
+ if not self.labels:
248
+ raise ValueError("labels field must contain at least one label")
249
+
250
+ @staticmethod
251
+ def _strip_for_label_compare(text: str) -> str:
252
+ """Strip whitespace and special characters for label comparison."""
253
+ return re.sub(r"[^a-z0-9]", "", text)
254
+
255
+ def _find_value_in_table(self, table_array, value: str) -> list[tuple[int, int]]: # type: ignore[no-untyped-def]
256
+ """Find all cells matching the value in a table."""
257
+ matches = []
258
+ rows, cols = table_array.shape
259
+
260
+ for row_idx in range(rows):
261
+ for col_idx in range(cols):
262
+ cell_text = normalize_text(str(table_array[row_idx, col_idx]))
263
+
264
+ # Try exact fuzzy match first
265
+ threshold = max(0.5, 1.0 - (self.max_diffs / max(len(value), 1)))
266
+ similarity = fuzz.ratio(value, cell_text) / 100.0
267
+
268
+ if similarity >= threshold:
269
+ matches.append((row_idx, col_idx))
270
+ elif self.normalize_numbers and numbers_match(value, cell_text, self.relative_tolerance):
271
+ matches.append((row_idx, col_idx))
272
+
273
+ # Fallback: try composite value decomposition for values like "25 (13.0%)"
274
+ # where the number and percentage are in adjacent cells.
275
+ if not matches and self.normalize_numbers:
276
+ matches = self._find_composite_value_in_table(table_array, value)
277
+
278
+ return matches
279
+
280
+ def _find_composite_value_in_table(self, table_array, value: str) -> list[tuple[int, int]]: # type: ignore[no-untyped-def]
281
+ """Match a composite value against adjacent cells in a table.
282
+
283
+ Handles values like "25 (13.0%)" where "25" is in one cell and
284
+ "13.0%" is in an adjacent cell to the right.
285
+ """
286
+ parts = extract_numeric_parts(value)
287
+ if len(parts) < 2:
288
+ return []
289
+
290
+ rows, cols = table_array.shape
291
+ matches = []
292
+
293
+ for row_idx in range(rows):
294
+ for col_idx in range(cols):
295
+ cell_text = normalize_text(str(table_array[row_idx, col_idx]))
296
+
297
+ # Check if this cell matches the first numeric part
298
+ if not numbers_match(parts[0], cell_text, self.relative_tolerance):
299
+ continue
300
+
301
+ # Check if adjacent cells to the right match remaining parts
302
+ all_parts_found = True
303
+ for part_offset, part in enumerate(parts[1:], start=1):
304
+ adj_col = col_idx + part_offset
305
+ if adj_col >= cols:
306
+ all_parts_found = False
307
+ break
308
+ adj_text = normalize_text(str(table_array[row_idx, adj_col]))
309
+ if not numbers_match(part, adj_text, self.relative_tolerance):
310
+ all_parts_found = False
311
+ break
312
+
313
+ if all_parts_found:
314
+ matches.append((row_idx, col_idx))
315
+
316
+ return matches
317
+
318
+ def _label_matches(self, label: str, cell_text: str, *, allow_partial: bool = True) -> bool:
319
+ """Match labels consistently across table cells and chart context."""
320
+ normalized_label = normalize_text(label)
321
+ normalized_cell = normalize_text(cell_text)
322
+
323
+ if normalized_label == normalized_cell:
324
+ return True
325
+
326
+ stripped_label = self._strip_for_label_compare(normalized_label)
327
+ stripped_cell = self._strip_for_label_compare(normalized_cell)
328
+ if stripped_label and stripped_label == stripped_cell:
329
+ return True
330
+
331
+ if not allow_partial:
332
+ return False
333
+
334
+ # Short labels such as ``Q1`` and ``US`` can validly appear as a
335
+ # distinct token inside a longer, candidate-local cell. Permit that
336
+ # narrow form while continuing to reject fuzzy/substring matches for
337
+ # short fragments: ``EU27`` must not bind to a value cell ``7``.
338
+ if min(len(stripped_label), len(stripped_cell)) < 3:
339
+ # Keep periods within abbreviations such as ``U.S.`` but split
340
+ # slash- and hyphen-delimited chart labels such as ``Q1/2024``.
341
+ # A compacted value like ``us7`` remains a different token and
342
+ # cannot donate the ``US`` label.
343
+ cell_tokens = re.findall(r"[a-z0-9]+(?:\.[a-z0-9]+)*", normalized_cell)
344
+ label_tokens = re.findall(r"[a-z0-9]+(?:\.[a-z0-9]+)*", normalized_label)
345
+ delimited_label_match = bool(
346
+ len(label_tokens) > 1
347
+ and any(
348
+ [self._strip_for_label_compare(token) for token in cell_tokens[start : start + len(label_tokens)]]
349
+ == [self._strip_for_label_compare(token) for token in label_tokens]
350
+ for start in range(len(cell_tokens) - len(label_tokens) + 1)
351
+ )
352
+ )
353
+ return (
354
+ bool(
355
+ re.search(r"[a-z]", stripped_label)
356
+ and any(self._strip_for_label_compare(token) == stripped_label for token in cell_tokens)
357
+ )
358
+ or delimited_label_match
359
+ )
360
+
361
+ threshold = max(0.5, 1.0 - (self.max_diffs / max(len(normalized_label), 1)))
362
+ return (
363
+ fuzz.partial_ratio(normalized_label, normalized_cell) / 100.0 >= threshold
364
+ or stripped_label in stripped_cell
365
+ )
366
+
367
+ @staticmethod
368
+ def _two_digit_year_matches_candidate_evidence(label: str, evidence: str) -> bool:
369
+ """Match chart shorthand such as ``09`` to a local ``2009`` token.
370
+
371
+ This equivalence is deliberately narrower than the general label
372
+ matcher. It is used only after a value candidate has selected its own
373
+ row and authored header chain, so a year in a caption or another body
374
+ row cannot repair an otherwise unrelated value. Tokenization also
375
+ prevents compact values such as ``20090`` or ``2009.0`` from donating
376
+ a year.
377
+ """
378
+ normalized_label = normalize_text(label)
379
+ if not re.fullmatch(r"\d{2}", normalized_label):
380
+ return False
381
+
382
+ tokens = re.findall(r"[a-z0-9]+(?:\.[a-z0-9]+)*", normalize_text(evidence))
383
+ return any(
384
+ re.fullmatch(r"(?:19|20)\d{2}", token) is not None and token[-2:] == normalized_label for token in tokens
385
+ )
386
+
387
+ def _candidate_label_matches(self, label: str, evidence: str) -> bool:
388
+ """Match a label against evidence already bound to one value candidate."""
389
+ return self._label_matches(label, evidence) or self._two_digit_year_matches_candidate_evidence(label, evidence)
390
+
391
+ def _candidate_data_scope( # type: ignore[no-untyped-def]
392
+ self,
393
+ table_array,
394
+ value_row: int,
395
+ value_col: int,
396
+ table_data: TableData | None = None,
397
+ ) -> list[str]:
398
+ """Return only parser-authored evidence local to one value candidate."""
399
+ _, cols = table_array.shape
400
+ scope: list[str] = []
401
+ seen: set[str] = set()
402
+
403
+ def add(text: object) -> None:
404
+ normalized = normalize_text(str(text))
405
+ if normalized and normalized not in seen:
406
+ seen.add(normalized)
407
+ scope.append(normalized)
408
+
409
+ # A data label may come from the candidate row, but never from an
410
+ # arbitrary body row merely because it shares the value column.
411
+ for col_idx in range(cols):
412
+ if col_idx != value_col and (not table_data or (value_row, col_idx) not in table_data.header_cells):
413
+ add(table_array[value_row, col_idx])
414
+
415
+ if not table_data:
416
+ return scope
417
+
418
+ # ``parse_html_tables`` records every row containing a ``<th>`` in
419
+ # ``header_rows``, including row headers in ``<tbody>``. Cross-row
420
+ # labels may come from an explicit ``<thead>``, or from an initial
421
+ # top-level block whose cells are all ``<th>``. Body-row headers stay
422
+ # local to their own candidate metadata. Markdown tables have no HTML
423
+ # header cells and explicitly designate row zero as their header, so
424
+ # preserve that parser-authored convention too.
425
+ if table_data.thead_rows:
426
+ preceding_thead_rows = {row_idx for row_idx in table_data.thead_rows if row_idx < value_row}
427
+ authored_header_rows = set()
428
+ if preceding_thead_rows:
429
+ last_thead_row = max(preceding_thead_rows)
430
+ for row_idx in range(last_thead_row, -1, -1):
431
+ if row_idx not in preceding_thead_rows:
432
+ break
433
+ authored_header_rows.add(row_idx)
434
+ authored_header_rows |= {
435
+ row_idx
436
+ for row_idx in table_data.column_scope_rows
437
+ if row_idx < value_row and row_idx not in table_data.tfoot_rows
438
+ }
439
+ elif not table_data.header_cells:
440
+ authored_header_rows = {row_idx for row_idx in table_data.header_rows if row_idx < value_row}
441
+ else:
442
+ authored_header_rows = {
443
+ row_idx
444
+ for row_idx in table_data.column_scope_rows
445
+ if row_idx < value_row and row_idx not in table_data.tfoot_rows
446
+ }
447
+ for row_idx in range(value_row):
448
+ # A top-level implicit header may use an empty ``<td>`` as
449
+ # the corner filler (the common row-label/year layout). It
450
+ # is still authored as a header row only when every other
451
+ # cell is a real ``<th>``; non-empty ``<td>`` cells are body
452
+ # leakage and must terminate the inferred block. Explicit
453
+ # ``<tbody>`` rows are never eligible, even when all cells
454
+ # happen to be ``<th>``.
455
+ row_header_cells = [col_idx for col_idx in range(cols) if (row_idx, col_idx) in table_data.header_cells]
456
+ row_values = [str(table_array[row_idx, col_idx]).strip() for col_idx in range(cols)]
457
+ unique_nonempty = {value for value in row_values if value}
458
+ if row_idx in table_data.tbody_rows or row_idx in table_data.tfoot_rows:
459
+ break
460
+ if row_idx in table_data.row_scope_rows:
461
+ break
462
+ if not row_header_cells:
463
+ # Match the title stripper's limited leading-row
464
+ # behavior: empty spacers and a uniform full-width
465
+ # ``<td colspan>`` title precede, rather than replace,
466
+ # an implicit ``<th>`` header block.
467
+ if not unique_nonempty or (cols > 1 and len(unique_nonempty) == 1 and all(row_values)):
468
+ continue
469
+ break
470
+ if any(
471
+ (row_idx, col_idx) not in table_data.header_cells and str(table_array[row_idx, col_idx]).strip()
472
+ for col_idx in range(cols)
473
+ ):
474
+ break
475
+ authored_header_rows.add(row_idx)
476
+
477
+ # Parser-expanded rowspan/colspan metadata provides the candidate's
478
+ # local row and column header chains.
479
+ for header_entry in table_data.row_headers.get(value_row, []):
480
+ add(header_entry[1])
481
+ column_headers = [
482
+ header_entry
483
+ for header_entry in table_data.col_headers.get(value_col, [])
484
+ if header_entry[0] in authored_header_rows
485
+ ]
486
+ for header_entry in column_headers:
487
+ add(header_entry[1])
488
+
489
+ # ``col_headers`` contains only ``<th>`` cells. An explicit
490
+ # ``<thead>`` is authored header provenance even when a level uses a
491
+ # ``<td>``, so fill only header rows not already represented by that
492
+ # metadata from the physical candidate column. This is also the
493
+ # malformed-HTML fallback; it never consults another body row. A
494
+ # row-scoped ``<th>`` excludes only its own physical cell: an adjacent
495
+ # ``<td>`` in the same explicit header row may still be the column
496
+ # label for this candidate.
497
+ represented_header_rows = {header_entry[0] for header_entry in column_headers}
498
+ for header_row in sorted(authored_header_rows - represented_header_rows):
499
+ if (header_row, value_col) not in table_data.header_cells:
500
+ add(table_array[header_row, value_col])
501
+
502
+ return scope
503
+
504
+ def _labels_match_candidate_scope( # type: ignore[no-untyped-def]
505
+ self,
506
+ table_array,
507
+ value_row: int,
508
+ value_col: int,
509
+ labels: list[str],
510
+ table_data: TableData | None = None,
511
+ ) -> tuple[list[str], list[str]]:
512
+ """Return labels matched and missing from one value candidate's scope."""
513
+ scope = self._candidate_data_scope(table_array, value_row, value_col, table_data)
514
+ matched = [label for label in labels if any(self._candidate_label_matches(label, item) for item in scope)]
515
+ missing = [label for label in labels if label not in matched]
516
+ return matched, missing
517
+
518
+ def _all_td_candidate_scope( # type: ignore[no-untyped-def]
519
+ self,
520
+ table_array,
521
+ value_row: int,
522
+ value_col: int,
523
+ table_data: TableData,
524
+ ) -> list[str]:
525
+ """Return the legacy row/column scope for an un-authored HTML grid.
526
+
527
+ Model output sometimes uses only ``<td>`` elements, even for a visually
528
+ obvious first-row or first-column header. Such a table has no authored
529
+ header provenance to follow. The caller may use this broader scope only
530
+ when it identifies exactly one value candidate; otherwise cross-row
531
+ borrowing would make duplicate values ambiguous.
532
+ """
533
+ rows, _ = table_array.shape
534
+ scope = self._candidate_data_scope(table_array, value_row, value_col, table_data)
535
+ seen = set(scope)
536
+
537
+ for row_idx in range(rows):
538
+ if row_idx == value_row:
539
+ continue
540
+ normalized = normalize_text(str(table_array[row_idx, value_col]))
541
+ if normalized and normalized not in seen:
542
+ seen.add(normalized)
543
+ scope.append(normalized)
544
+
545
+ return scope
546
+
547
+ def _unique_all_td_fallback_candidate( # type: ignore[no-untyped-def]
548
+ self,
549
+ table_array,
550
+ value_matches: list[tuple[int, int]],
551
+ table_data: TableData,
552
+ ) -> tuple[int, int] | None:
553
+ """Resolve one unambiguous value candidate in an all-``td`` HTML table."""
554
+ if table_data.header_cells or table_data.header_rows:
555
+ return None
556
+
557
+ winners = []
558
+ for value_row, value_col in value_matches:
559
+ scope = self._all_td_candidate_scope(table_array, value_row, value_col, table_data)
560
+ if all(any(self._label_matches(label, item) for item in scope) for label in self.labels):
561
+ winners.append((value_row, value_col))
562
+
563
+ return winners[0] if len(winners) == 1 else None
564
+
565
+ def _extract_formatted_labels(self, context: str) -> set[str]:
566
+ """Extract bold text and headings from markdown/HTML context."""
567
+ formatted_labels = set()
568
+
569
+ # Extract markdown headings: # Title, ## Title, etc.
570
+ heading_pattern = r"^#{1,6}\s+(.+?)$"
571
+ for match in re.finditer(heading_pattern, context, re.MULTILINE):
572
+ formatted_labels.add(normalize_text(match.group(1)))
573
+
574
+ # Extract bold text: **Bold Text**
575
+ bold_pattern = r"\*\*(.+?)\*\*"
576
+ for match in re.finditer(bold_pattern, context):
577
+ formatted_labels.add(normalize_text(match.group(1)))
578
+
579
+ # Extract HTML headings: <h1>Title</h1>, etc.
580
+ html_heading_pattern = r"<h[1-6][^>]*>(.+?)</h[1-6]>"
581
+ for match in re.finditer(html_heading_pattern, context, re.IGNORECASE):
582
+ formatted_labels.add(normalize_text(match.group(1)))
583
+
584
+ # Extract HTML bold: <strong>Text</strong>, <b>Text</b>
585
+ html_bold_pattern = r"<(?:strong|b)[^>]*>(.+?)</(?:strong|b)>"
586
+ for match in re.finditer(html_bold_pattern, context, re.IGNORECASE):
587
+ formatted_labels.add(normalize_text(match.group(1)))
588
+
589
+ return formatted_labels
590
+
591
+ def _label_exists_in_table( # type: ignore[no-untyped-def]
592
+ self, table_array, label: str, table_data: TableData | None = None
593
+ ) -> bool:
594
+ """Check if label matches ANY cell in the table (not just same row/col).
595
+
596
+ This prevents labels like "Retail Ecommerce Sales" from being treated
597
+ as title labels when they are actually column headers in the table.
598
+ """
599
+ rows, cols = table_array.shape
600
+
601
+ for r in range(rows):
602
+ for c in range(cols):
603
+ cell_text = normalize_text(str(table_array[r, c]))
604
+ if self._label_matches(label, cell_text):
605
+ return True
606
+
607
+ # Also check col_headers and row_headers
608
+ if table_data:
609
+ for headers in (table_data.col_headers, table_data.row_headers):
610
+ if headers:
611
+ for entries in headers.values():
612
+ for entry in entries:
613
+ header_text = normalize_text(str(entry[1]))
614
+ if self._label_matches(label, header_text):
615
+ return True
616
+
617
+ return False
618
+
619
+ def _is_label_in_formatted_context(self, context: str, label: str) -> bool:
620
+ """Check if label appears as formatted text (bold/heading) in context.
621
+
622
+ Uses full-string ratio (not partial/substring) to avoid matching a label
623
+ that is merely one term inside a longer heading. For example,
624
+ "Summary innovation index" should match "Summary innovation index (Individual Countries)"
625
+ but "Workforce" must NOT match "Evolution of the workforce, revenue and productivity".
626
+ """
627
+ formatted_labels = self._extract_formatted_labels(context)
628
+ normalized_label = normalize_text(label)
629
+ stripped_label = self._strip_for_label_compare(normalized_label)
630
+
631
+ # Use a fixed threshold with full-string ratio so that the label must
632
+ # cover most of the formatted text (or vice-versa).
633
+ threshold = 0.60
634
+
635
+ for formatted_label in formatted_labels:
636
+ if self._label_matches(normalized_label, formatted_label, allow_partial=False):
637
+ return True
638
+ similarity = fuzz.ratio(normalized_label, formatted_label) / 100.0
639
+ if (
640
+ min(len(stripped_label), len(self._strip_for_label_compare(formatted_label))) >= 3
641
+ and similarity >= threshold
642
+ ):
643
+ return True
644
+ # Fallback: compare with whitespace/special chars stripped
645
+ stripped_formatted = self._strip_for_label_compare(formatted_label)
646
+ if min(len(stripped_label), len(stripped_formatted)) >= 3:
647
+ similarity = fuzz.ratio(stripped_label, stripped_formatted) / 100.0
648
+ if similarity >= threshold:
649
+ return True
650
+
651
+ return False
652
+
653
+ def _is_label_in_heading_or_caption(self, context: str, label: str) -> bool:
654
+ """Check if label appears in a heading or <caption> element in context.
655
+
656
+ Headings and captions are strong table-identity signals (e.g. "## LDC/LLDCs"
657
+ or "<caption>Solar PV (modules) ...</caption>"). Uses partial_ratio since
658
+ headings/captions are typically longer than the label.
659
+ """
660
+ # Check <caption> elements
661
+ for match in re.finditer(r"<caption[^>]*>(.+?)</caption>", context, re.IGNORECASE):
662
+ if self._label_matches(label, match.group(1)):
663
+ return True
664
+
665
+ # Check markdown headings (# Title, ## Title, etc.)
666
+ for match in re.finditer(r"^#{1,6}\s+(.+?)$", context, re.MULTILINE):
667
+ if self._label_matches(label, match.group(1)):
668
+ return True
669
+
670
+ # Check HTML headings. Unlike generic bold text, an authored heading
671
+ # is a strong chart-identity signal even when the same token also
672
+ # appears in a table body cell.
673
+ for match in re.finditer(r"<h[1-6][^>]*>(.+?)</h[1-6]>", context, re.IGNORECASE | re.DOTALL):
674
+ if self._label_matches(label, match.group(1)):
675
+ return True
676
+
677
+ return False
678
+
679
+ def run(self, content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
680
+ """Check if value is associated with all labels in any table."""
681
+ # ``[]`` is a real cache hit for table-free content. Only ``None``
682
+ # means this rule was invoked directly and must parse for itself.
683
+ tables_to_check = self.parsed_tables
684
+ if tables_to_check is None:
685
+ tables_to_check = parse_chart_tables(content)
686
+
687
+ if not tables_to_check:
688
+ return False, "No tables found in content", 0.0
689
+
690
+ all_failed_reasons = []
691
+
692
+ for table_data in tables_to_check:
693
+ table_array = table_data.data
694
+ # Only use context BEFORE the table (chart titles/headings).
695
+ # context_after is excluded to avoid matching labels that
696
+ context = table_data.context_before.strip()
697
+
698
+ # Find all cells matching the value
699
+ value_matches = self._find_value_in_table(table_array, self.value)
700
+
701
+ if not value_matches:
702
+ continue # Try next table
703
+
704
+ # Some model-generated HTML uses no ``<th>`` elements at all.
705
+ # Preserve its visually vertical row/column association only when
706
+ # that broader evidence selects exactly one matching value cell.
707
+ all_td_fallback_candidate = self._unique_all_td_fallback_candidate(
708
+ table_array,
709
+ value_matches,
710
+ table_data,
711
+ )
712
+
713
+ # For each matching cell, try validation phases
714
+ for value_row, value_col in value_matches:
715
+ # PHASE 1: every data label must bind to this one value
716
+ # candidate's row and parser-authored header chains.
717
+ data_labels, missing_labels_strict = self._labels_match_candidate_scope(
718
+ table_array, value_row, value_col, self.labels, table_data
719
+ )
720
+
721
+ if not missing_labels_strict:
722
+ return (
723
+ True,
724
+ (
725
+ f"Value '{self.value}' found with all labels in candidate-local scope "
726
+ f"at ({value_row}, {value_col})"
727
+ ),
728
+ 1.0,
729
+ )
730
+
731
+ if all_td_fallback_candidate == (value_row, value_col):
732
+ return (
733
+ True,
734
+ (
735
+ f"Value '{self.value}' found with all labels in unique all-td row/column scope "
736
+ f"at ({value_row}, {value_col})"
737
+ ),
738
+ 1.0,
739
+ )
740
+
741
+ # PHASE 2: Try context-aware matching if context available
742
+ if context:
743
+ # Classify labels based on WHERE they are found:
744
+ # 1. First check if label is in table (associated with value)
745
+ # 2. If not in table, check if it's in formatted context
746
+ # This ensures labels that appear in BOTH table and context
747
+ # are correctly classified as data labels (found in table)
748
+
749
+ title_labels = [] # Labels found in formatted context only
750
+ missing_labels = [] # Labels not found anywhere
751
+
752
+ for label in missing_labels_strict:
753
+ # A label found elsewhere in the table is data, not
754
+ # chart identity context. It must bind locally.
755
+ if self._label_exists_in_table(table_array, label, table_data):
756
+ # A genuine heading/caption establishes chart
757
+ # identity even when the same text also occurs in
758
+ # another table cell. Ordinary formatted context
759
+ # does not receive this exception.
760
+ if self._is_label_in_heading_or_caption(context, label):
761
+ title_labels.append(label)
762
+ else:
763
+ missing_labels.append(label)
764
+ elif self._is_label_in_formatted_context(
765
+ context, label
766
+ ) or self._is_label_in_heading_or_caption(context, label):
767
+ title_labels.append(label)
768
+ else:
769
+ missing_labels.append(label)
770
+
771
+ # Success if all labels are found (either in table or in context)
772
+ if not missing_labels:
773
+ # Success - all labels found
774
+ if title_labels:
775
+ return (
776
+ True,
777
+ (
778
+ f"Value '{self.value}' found with data labels {data_labels} "
779
+ f"in candidate-local scope and title labels "
780
+ f"{title_labels} in context "
781
+ f"at ({value_row}, {value_col})"
782
+ ),
783
+ 1.0,
784
+ )
785
+ else:
786
+ # All labels in table (no title labels needed)
787
+ return (
788
+ True,
789
+ (
790
+ f"Value '{self.value}' found with all labels in candidate-local scope "
791
+ f"at ({value_row}, {value_col})"
792
+ ),
793
+ 1.0,
794
+ )
795
+
796
+ # Track failure reason
797
+ all_failed_reasons.append(
798
+ f"Value at ({value_row}, {value_col}) missing labels: {missing_labels} "
799
+ f"(data labels {data_labels} in candidate-local scope, "
800
+ f"title labels {title_labels} in context)"
801
+ )
802
+ else:
803
+ # No context available, use strict failure reason
804
+ all_failed_reasons.append(
805
+ f"Value at ({value_row}, {value_col}) missing labels: {missing_labels_strict}"
806
+ )
807
+
808
+ if not all_failed_reasons:
809
+ return False, f"Value '{self.value}' not found in any table", 0.0
810
+
811
+ return (
812
+ False,
813
+ f"Value found but labels not associated: {'; '.join(all_failed_reasons[:3])}",
814
+ 0.0,
815
+ )
816
+
817
+
818
+ class ChartDataArrayLabelsRule(ParseTestRule):
819
+ """
820
+ Test rule for validating chart data array labels (headers) in table conversions.
821
+
822
+ Computes a similarity score for each label, so partial matches get proportionally
823
+ lower scores. For example, "Length (in months)" matching against
824
+ "Length of the current and previous bull market (in months)" will get a partial
825
+ score rather than full credit.
826
+ """
827
+
828
+ def __init__(self, rule_data: ParseChartDataArrayLabelsRule | dict):
829
+ super().__init__(rule_data)
830
+ rule_data = cast(ParseChartDataArrayLabelsRule, self._rule_data)
831
+
832
+ if self.type != TestType.CHART_DATA_ARRAY_LABELS.value:
833
+ raise ValueError(f"Invalid type for ChartDataArrayLabelsRule: {self.type}")
834
+
835
+ # Load data from CSV if available (auto-detected by loader)
836
+ csv_path = rule_data.csv_path
837
+ if csv_path and Path(csv_path).exists():
838
+ skip = _detect_csv_skip_rows(csv_path)
839
+ df = pd.read_csv(csv_path, skiprows=skip)
840
+ # Convert to list of lists: [headers, row1, row2, ...]
841
+ self.data = [df.columns.tolist()] + df.values.tolist()
842
+ else:
843
+ self.data = rule_data.data
844
+
845
+ self.x_axis_shuffle = rule_data.x_axis_shuffle
846
+ self.transposed = rule_data.transposed
847
+
848
+ if not self.data or len(self.data) < 1:
849
+ raise ValueError("data field must contain at least one row (headers)")
850
+
851
+ # Headers are the first row
852
+ self.headers = self.data[0]
853
+
854
+ def _normalize_cell(self, value: Any) -> str:
855
+ """Normalize a cell value for comparison."""
856
+ return normalize_text(str(value))
857
+
858
+ def _label_similarity(self, expected: Any, actual: str) -> float:
859
+ """
860
+ Compute similarity score between expected and actual label (0.0 to 1.0).
861
+
862
+ Returns 1.0 for exact match, lower scores for partial matches.
863
+ """
864
+ expected_str = self._normalize_cell(expected)
865
+ actual_str = normalize_text(actual)
866
+
867
+ # Exact match
868
+ if expected_str == actual_str:
869
+ return 1.0
870
+
871
+ # Date-aware match (e.g. "2008-01-01 00:00:00" vs "Q1-2008")
872
+ if _dates_match(str(expected), actual):
873
+ return 1.0
874
+
875
+ # Numeric match (e.g. "415,000" vs "415000" or "415 000")
876
+ num_score = numeric_similarity(str(expected), actual)
877
+ if num_score is not None and num_score >= CELL_FUZZY_MATCH_THRESHOLD:
878
+ return num_score
879
+
880
+ # Use ratio for overall similarity (stricter than partial_ratio)
881
+ ratio_score = fuzz.ratio(expected_str, actual_str) / 100.0
882
+
883
+ # Also check partial_ratio for cases where one is substring of other
884
+ partial_score = fuzz.partial_ratio(expected_str, actual_str) / 100.0
885
+
886
+ # Weight: prefer ratio_score but give some credit for partial matches
887
+ # If actual is much shorter than expected, penalize more
888
+ length_ratio = min(len(actual_str), len(expected_str)) / max(len(actual_str), len(expected_str), 1)
889
+
890
+ # Combined score: ratio is primary, partial helps when lengths differ
891
+ score = ratio_score * 0.7 + partial_score * length_ratio * 0.3
892
+
893
+ return score
894
+
895
+ def _check_labels_ordered( # type: ignore[no-untyped-def]
896
+ self, table_array
897
+ ) -> tuple[float, float, list[tuple[str, str, float]]]:
898
+ """
899
+ Check labels in order (no shuffle).
900
+
901
+ Returns: (total_score, max_possible_score, [(expected, actual, score), ...])
902
+ """
903
+ _, cols = table_array.shape
904
+ if cols != len(self.headers):
905
+ return 0.0, float(len(self.headers)), [("", "", 0.0)]
906
+
907
+ total_score = 0.0
908
+ label_scores: list[tuple[str, str, float]] = []
909
+
910
+ for col_idx, expected_label in enumerate(self.headers):
911
+ actual_label = str(table_array[0, col_idx])
912
+ score = self._label_similarity(expected_label, actual_label)
913
+ total_score += score
914
+ label_scores.append((str(expected_label), actual_label, score))
915
+
916
+ return total_score, float(len(self.headers)), label_scores
917
+
918
+ def _check_labels_shuffled( # type: ignore[no-untyped-def]
919
+ self, table_array
920
+ ) -> tuple[float, float, list[tuple[str, str, float]]]:
921
+ """
922
+ Check labels with x-axis shuffle (columns can be reordered).
923
+
924
+ Returns: (total_score, max_possible_score, [(expected, actual, score), ...])
925
+ """
926
+ _, cols = table_array.shape
927
+ if cols != len(self.headers):
928
+ return 0.0, float(len(self.headers)), [("", "", 0.0)]
929
+
930
+ actual_labels = [str(table_array[0, col_idx]) for col_idx in range(cols)]
931
+ total_score = 0.0
932
+ label_scores: list[tuple[str, str, float]] = []
933
+ used_actual: set[int] = set()
934
+
935
+ for expected_label in self.headers:
936
+ best_score = 0.0
937
+ best_idx = -1
938
+ best_actual = ""
939
+
940
+ for act_idx, actual_label in enumerate(actual_labels):
941
+ if act_idx in used_actual:
942
+ continue
943
+ score = self._label_similarity(expected_label, actual_label)
944
+ if score > best_score:
945
+ best_score = score
946
+ best_idx = act_idx
947
+ best_actual = actual_label
948
+
949
+ if best_idx >= 0:
950
+ used_actual.add(best_idx)
951
+ total_score += best_score
952
+ label_scores.append((str(expected_label), best_actual, best_score))
953
+ else:
954
+ label_scores.append((str(expected_label), "", 0.0))
955
+
956
+ return total_score, float(len(self.headers)), label_scores
957
+
958
+ def run(self, content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
959
+ """
960
+ Check if expected labels match any table in content.
961
+
962
+ Returns a score-based result where partial matches get proportionally lower scores.
963
+ """
964
+ tables_to_check = self.parsed_tables
965
+ if tables_to_check is None:
966
+ tables_to_check = parse_chart_tables(content)
967
+
968
+ if not tables_to_check:
969
+ return False, "No tables found in content", 0.0
970
+
971
+ best_score = 0.0
972
+ best_total = float(len(self.headers))
973
+ best_label_scores: list[tuple[str, str, float]] = []
974
+
975
+ for table_data in tables_to_check:
976
+ # Try both orientations and keep the best score
977
+ orientations = [table_data.data, table_data.data.T]
978
+ for data in orientations:
979
+ if self.x_axis_shuffle:
980
+ score, total, label_scores = self._check_labels_shuffled(data)
981
+ else:
982
+ score, total, label_scores = self._check_labels_ordered(data)
983
+
984
+ if score > best_score:
985
+ best_score = score
986
+ best_total = total
987
+ best_label_scores = label_scores
988
+
989
+ # Format the result with individual label scores
990
+ if best_total == 0:
991
+ return False, "No labels to check", 0.0
992
+
993
+ score_pct = (best_score / best_total) * 100 if best_total > 0 else 0
994
+ score_normalized = score_pct / 100.0
995
+
996
+ # Build details for labels that aren't perfect matches
997
+ imperfect = [f"'{exp}' vs '{act}' ({sc:.0%})" for exp, act, sc in best_label_scores if sc < 1.0]
998
+
999
+ if score_pct == 100:
1000
+ return True, f"Labels: {score_pct:.1f}% ({best_score:.2f}/{best_total:.0f})", 1.0
1001
+
1002
+ if imperfect:
1003
+ return (
1004
+ False,
1005
+ (f"Labels: {score_pct:.1f}% ({best_score:.2f}/{best_total:.0f}). Partial: {'; '.join(imperfect[:3])}"),
1006
+ score_normalized,
1007
+ )
1008
+ return (
1009
+ False,
1010
+ f"Labels: {score_pct:.1f}% ({best_score:.2f}/{best_total:.0f})",
1011
+ score_normalized,
1012
+ )
1013
+
1014
+
1015
+ class ChartDataArrayDataRule(ParseTestRule):
1016
+ """
1017
+ Test rule for validating chart data array values (excluding headers) in table conversions.
1018
+
1019
+ Uses strict matching with number normalization. Supports row/column shuffling
1020
+ for charts where axis order doesn't matter.
1021
+ """
1022
+
1023
+ def __init__(self, rule_data: ParseChartDataArrayDataRule | dict):
1024
+ super().__init__(rule_data)
1025
+ rule_data = cast(ParseChartDataArrayDataRule, self._rule_data)
1026
+
1027
+ if self.type != TestType.CHART_DATA_ARRAY_DATA.value:
1028
+ raise ValueError(f"Invalid type for ChartDataArrayDataRule: {self.type}")
1029
+
1030
+ # Load data from CSV if available (auto-detected by loader)
1031
+ csv_path = rule_data.csv_path
1032
+ if csv_path and Path(csv_path).exists():
1033
+ skip = _detect_csv_skip_rows(csv_path)
1034
+ df = pd.read_csv(csv_path, skiprows=skip)
1035
+ # Convert to list of lists: [headers, row1, row2, ...]
1036
+ self.data = [df.columns.tolist()] + df.values.tolist()
1037
+ else:
1038
+ self.data = rule_data.data
1039
+
1040
+ self.x_axis_shuffle = rule_data.x_axis_shuffle # columns can reorder
1041
+ self.y_axis_shuffle = rule_data.y_axis_shuffle # rows can reorder
1042
+ self.normalize_numbers = rule_data.normalize_numbers
1043
+ self.transposed = rule_data.transposed
1044
+
1045
+ if not self.data or len(self.data) < 2:
1046
+ raise ValueError("data field must contain at least header row and one data row")
1047
+
1048
+ # Data rows are everything after the header
1049
+ self.data_rows = self.data[1:]
1050
+ self.expected_cols = len(self.data[0]) if self.data else 0
1051
+
1052
+ def _normalize_cell(self, value: Any) -> str:
1053
+ """Normalize a cell value for comparison."""
1054
+ return normalize_text(str(value))
1055
+
1056
+ def _cells_match(self, expected: Any, actual: str) -> bool:
1057
+ """Check if two cell values match (with fuzzy/number matching)."""
1058
+ return self._cell_score(expected, actual) >= CELL_FUZZY_MATCH_THRESHOLD
1059
+
1060
+ @staticmethod
1061
+ def _is_empty_or_nan(value: Any) -> bool:
1062
+ """Check if a value represents an empty/missing cell."""
1063
+ if value is None:
1064
+ return True
1065
+ s = str(value).strip()
1066
+ return s in ("", "nan", "NaN", "NAN", "none", "None", "-", "—", "n/a", "N/A")
1067
+
1068
+ def _cell_score(self, expected: Any, actual: str) -> float:
1069
+ """
1070
+ Calculate similarity score between expected and actual cell values.
1071
+
1072
+ Returns float between 0.0 and 1.0.
1073
+ """
1074
+ # Expected nan/empty matches any empty-like or zero actual value
1075
+ if self._is_empty_or_nan(expected):
1076
+ if self._is_empty_or_nan(actual):
1077
+ return 1.0
1078
+ # Also accept "0" / 0.0 as a representation of missing data
1079
+ num = normalize_number_string(actual)
1080
+ if num is not None and num == 0:
1081
+ return 1.0
1082
+
1083
+ expected_str = self._normalize_cell(expected)
1084
+ actual_str = normalize_text(actual)
1085
+
1086
+ # Exact match after normalization
1087
+ if expected_str == actual_str:
1088
+ return 1.0
1089
+
1090
+ # Date-aware match (e.g. "2008-01-01 00:00:00" vs "Q1-2008")
1091
+ if _dates_match(str(expected), actual):
1092
+ return 1.0
1093
+
1094
+ # Try numeric similarity first (graduated scoring)
1095
+ # Use original strings (not normalized) for numeric comparison
1096
+ # since normalize_text removes decimal points
1097
+ if self.normalize_numbers:
1098
+ num_score = numeric_similarity(str(expected), actual)
1099
+ if num_score is not None:
1100
+ # If direct comparison is poor, the CSV value may be in a
1101
+ # different magnitude than the displayed value (e.g. CSV has
1102
+ # raw 495926400 but chart shows ~495 meaning "millions").
1103
+ # Try dividing expected by common scales and keep best score.
1104
+ if num_score < CELL_FUZZY_MATCH_THRESHOLD:
1105
+ exp_num = normalize_number_string(str(expected))
1106
+ if exp_num is not None and exp_num != 0:
1107
+ for scale in (1e3, 1e6, 1e9, 1e12):
1108
+ scaled_score = numeric_similarity(str(exp_num / scale), actual)
1109
+ if scaled_score is not None and scaled_score > num_score:
1110
+ num_score = scaled_score
1111
+ return num_score
1112
+
1113
+ # Fall back to fuzzy string matching
1114
+ similarity = fuzz.ratio(expected_str, actual_str) / 100.0
1115
+ return similarity
1116
+
1117
+ def _find_matching_column( # type: ignore[no-untyped-def]
1118
+ self, expected_col_data: list, table_array, used_cols: set, start_row: int = 1
1119
+ ) -> int | None:
1120
+ """Find a column in table that matches expected column data (rows in order)."""
1121
+ rows, cols = table_array.shape
1122
+ data_rows = rows - start_row
1123
+ if len(expected_col_data) != data_rows:
1124
+ return None
1125
+
1126
+ for col_idx in range(cols):
1127
+ if col_idx in used_cols:
1128
+ continue
1129
+ all_match = True
1130
+ for data_row_idx, expected_val in enumerate(expected_col_data):
1131
+ actual_row_idx = data_row_idx + start_row
1132
+ actual_val = str(table_array[actual_row_idx, col_idx])
1133
+ if not self._cells_match(expected_val, actual_val):
1134
+ all_match = False
1135
+ break
1136
+ if all_match:
1137
+ return col_idx
1138
+ return None
1139
+
1140
+ def _find_matching_column_unordered( # type: ignore[no-untyped-def]
1141
+ self, expected_col_data: list, table_array, used_cols: set, start_row: int = 1
1142
+ ) -> int | None:
1143
+ """
1144
+ Find a column in table that matches expected column data as a multiset.
1145
+
1146
+ This is used when both x and y axis shuffle are enabled - the column values
1147
+ must all be present but can be in any row order.
1148
+ """
1149
+ rows, cols = table_array.shape
1150
+ data_rows = rows - start_row
1151
+ if len(expected_col_data) != data_rows:
1152
+ return None
1153
+
1154
+ for col_idx in range(cols):
1155
+ if col_idx in used_cols:
1156
+ continue
1157
+
1158
+ # Get actual column values
1159
+ actual_vals = [str(table_array[row_idx, col_idx]) for row_idx in range(start_row, rows)]
1160
+
1161
+ # Try to match each expected value to an actual value (multiset match)
1162
+ used_actual: set[int] = set()
1163
+ all_matched = True
1164
+
1165
+ for expected_val in expected_col_data:
1166
+ found = False
1167
+ for act_idx, actual_val in enumerate(actual_vals):
1168
+ if act_idx not in used_actual and self._cells_match(expected_val, actual_val):
1169
+ used_actual.add(act_idx)
1170
+ found = True
1171
+ break
1172
+ if not found:
1173
+ all_matched = False
1174
+ break
1175
+
1176
+ if all_matched:
1177
+ return col_idx
1178
+ return None
1179
+
1180
+ def _find_matching_row( # type: ignore[no-untyped-def]
1181
+ self, expected_row: list, table_array, used_rows: set, start_row: int = 1
1182
+ ) -> int | None:
1183
+ """Find a row in table that matches expected row data."""
1184
+ rows, cols = table_array.shape
1185
+ if len(expected_row) != cols:
1186
+ return None
1187
+
1188
+ for row_idx in range(start_row, rows):
1189
+ if row_idx in used_rows:
1190
+ continue
1191
+ all_match = True
1192
+ for col_idx, expected_val in enumerate(expected_row):
1193
+ actual_val = str(table_array[row_idx, col_idx])
1194
+ if not self._cells_match(expected_val, actual_val):
1195
+ all_match = False
1196
+ break
1197
+ if all_match:
1198
+ return row_idx
1199
+ return None
1200
+
1201
+ def _check_data(self, table_array) -> tuple[float, float, list[str]]: # type: ignore[no-untyped-def]
1202
+ """
1203
+ Check how many data cells match using score-based comparison.
1204
+
1205
+ Returns: (score, max_score, mismatched_details)
1206
+ """
1207
+ actual_rows, actual_cols = table_array.shape
1208
+ expected_data_rows = len(self.data_rows)
1209
+
1210
+ # Data starts at row 1 (row 0 is header)
1211
+ actual_data_rows = actual_rows - 1
1212
+
1213
+ # Columns must match; rows may differ (partial credit for missing rows)
1214
+ if actual_cols != self.expected_cols:
1215
+ return (
1216
+ 0.0,
1217
+ float(expected_data_rows * self.expected_cols),
1218
+ [f"Column mismatch: expected {self.expected_cols} cols, got {actual_cols}"],
1219
+ )
1220
+
1221
+ total_cells = float(expected_data_rows * self.expected_cols)
1222
+ mismatches: list[str] = []
1223
+
1224
+ if actual_data_rows != expected_data_rows:
1225
+ mismatches.append(f"Row count mismatch: expected {expected_data_rows}, got {actual_data_rows}")
1226
+
1227
+ data_rows = self.data_rows
1228
+
1229
+ # Case 1: No shuffling - direct position comparison
1230
+ if not self.x_axis_shuffle and not self.y_axis_shuffle:
1231
+ total_score = 0.0
1232
+ row_details: list[tuple[int, float, list[str], list[str]]] = []
1233
+ matchable_rows = min(expected_data_rows, actual_data_rows)
1234
+ for row_idx, expected_row in enumerate(data_rows):
1235
+ if row_idx >= matchable_rows:
1236
+ # Remaining expected rows have no actual counterpart (score 0)
1237
+ break
1238
+ actual_row_idx = row_idx + 1 # Skip header row
1239
+ row_score = 0.0
1240
+ actual_row_values: list[str] = []
1241
+ for col_idx, expected_val in enumerate(expected_row):
1242
+ actual_val = str(table_array[actual_row_idx, col_idx])
1243
+ actual_row_values.append(actual_val)
1244
+ cell_score = self._cell_score(expected_val, actual_val)
1245
+ row_score += cell_score
1246
+ total_score += row_score
1247
+ max_row_score = len(expected_row)
1248
+ row_pct = row_score / max_row_score if max_row_score > 0 else 0
1249
+ if row_pct < 1.0:
1250
+ row_details.append(
1251
+ (
1252
+ row_idx + 1,
1253
+ row_pct,
1254
+ [str(v) for v in expected_row],
1255
+ actual_row_values,
1256
+ )
1257
+ )
1258
+ if row_details:
1259
+ row_details.sort(key=lambda x: x[1]) # worst rows first
1260
+ for r_idx, r_pct, exp_vals, act_vals in row_details[:5]:
1261
+ mismatches.append(f"Row {r_idx}: {r_pct:.0%} | Expected: {exp_vals} | Actual: {act_vals}")
1262
+ return total_score, total_cells, mismatches
1263
+
1264
+ # Case 2: Y-axis shuffle only (rows can reorder, columns fixed)
1265
+ if self.y_axis_shuffle and not self.x_axis_shuffle:
1266
+ used_rows: set[int] = set()
1267
+ total_score = 0.0
1268
+ row_details: list[tuple[int, float, list[str], list[str]]] = [] # type: ignore[no-redef]
1269
+
1270
+ for exp_row_idx, expected_row in enumerate(data_rows):
1271
+ # Find best matching row using scores
1272
+ best_row_score = 0.0
1273
+ best_row_idx = -1
1274
+ best_actual_values: list[str] = []
1275
+
1276
+ for act_row_idx in range(1, actual_rows): # Skip header
1277
+ if act_row_idx in used_rows:
1278
+ continue
1279
+ # Calculate score for this row pairing
1280
+ row_score = 0.0
1281
+ actual_values: list[str] = []
1282
+ for col_idx, expected_val in enumerate(expected_row):
1283
+ actual_val = str(table_array[act_row_idx, col_idx])
1284
+ actual_values.append(actual_val)
1285
+ row_score += self._cell_score(expected_val, actual_val)
1286
+
1287
+ if row_score > best_row_score:
1288
+ best_row_score = row_score
1289
+ best_row_idx = act_row_idx
1290
+ best_actual_values = actual_values
1291
+
1292
+ if best_row_idx >= 0:
1293
+ used_rows.add(best_row_idx)
1294
+ total_score += best_row_score
1295
+ max_row_score = len(expected_row)
1296
+ row_pct = best_row_score / max_row_score if max_row_score > 0 else 0
1297
+ if row_pct < 1.0:
1298
+ row_details.append(
1299
+ (
1300
+ exp_row_idx + 1,
1301
+ row_pct,
1302
+ [str(v) for v in expected_row],
1303
+ best_actual_values,
1304
+ )
1305
+ )
1306
+
1307
+ if row_details:
1308
+ row_details.sort(key=lambda x: x[1]) # worst rows first
1309
+ for r_idx, r_pct, exp_vals, act_vals in row_details[:5]:
1310
+ mismatches.append(f"Row {r_idx}: {r_pct:.0%} | Expected: {exp_vals} | Actual: {act_vals}")
1311
+ return total_score, total_cells, mismatches
1312
+
1313
+ # Case 3: X-axis shuffle only (columns can reorder, rows fixed)
1314
+ if self.x_axis_shuffle and not self.y_axis_shuffle:
1315
+ matchable_rows = min(expected_data_rows, actual_data_rows)
1316
+ expected_cols_data = [
1317
+ [row[col_idx] for row in data_rows[:matchable_rows]] for col_idx in range(self.expected_cols)
1318
+ ]
1319
+ used_cols: set[int] = set()
1320
+ total_score = 0.0
1321
+ # Track column mapping: expected_col_idx -> actual_col_idx
1322
+ col_mapping: dict[int, int] = {}
1323
+
1324
+ for col_idx, expected_col in enumerate(expected_cols_data):
1325
+ # Find best matching column using scores
1326
+ best_col_score = 0.0
1327
+ best_col_idx = -1
1328
+
1329
+ for act_col_idx in range(actual_cols):
1330
+ if act_col_idx in used_cols:
1331
+ continue
1332
+ # Calculate score for this column pairing
1333
+ col_score = 0.0
1334
+ for row_idx, expected_val in enumerate(expected_col):
1335
+ actual_row_idx = row_idx + 1 # Skip header
1336
+ actual_val = str(table_array[actual_row_idx, act_col_idx])
1337
+ col_score += self._cell_score(expected_val, actual_val)
1338
+
1339
+ if col_score > best_col_score:
1340
+ best_col_score = col_score
1341
+ best_col_idx = act_col_idx
1342
+
1343
+ if best_col_idx >= 0:
1344
+ used_cols.add(best_col_idx)
1345
+ col_mapping[col_idx] = best_col_idx
1346
+ total_score += best_col_score
1347
+
1348
+ # Generate row-by-row comparison using the column mapping
1349
+ row_details: list[tuple[int, float, list[str], list[str]]] = [] # type: ignore[no-redef]
1350
+ for row_idx, expected_row in enumerate(data_rows):
1351
+ if row_idx >= matchable_rows:
1352
+ break
1353
+ actual_row_idx = row_idx + 1
1354
+ row_score = 0.0
1355
+ actual_row_values: list[str] = [] # type: ignore[no-redef]
1356
+ for col_idx, expected_val in enumerate(expected_row):
1357
+ act_col_idx = col_mapping.get(col_idx, col_idx)
1358
+ actual_val = str(table_array[actual_row_idx, act_col_idx])
1359
+ actual_row_values.append(actual_val)
1360
+ row_score += self._cell_score(expected_val, actual_val)
1361
+ max_row_score = len(expected_row)
1362
+ row_pct = row_score / max_row_score if max_row_score > 0 else 0
1363
+ if row_pct < 1.0:
1364
+ row_details.append(
1365
+ (
1366
+ row_idx + 1,
1367
+ row_pct,
1368
+ [str(v) for v in expected_row],
1369
+ actual_row_values,
1370
+ )
1371
+ )
1372
+
1373
+ if row_details:
1374
+ row_details.sort(key=lambda x: x[1]) # worst rows first
1375
+ for r_idx, r_pct, exp_vals, act_vals in row_details[:5]:
1376
+ mismatches.append(f"Row {r_idx}: {r_pct:.0%} | Expected: {exp_vals} | Actual: {act_vals}")
1377
+ return total_score, total_cells, mismatches
1378
+
1379
+ # Case 4: Both axes can shuffle
1380
+ # Use score-based matching: find best column mapping, then best row mapping
1381
+ total_score = 0.0
1382
+
1383
+ # For each expected column, find the best matching actual column
1384
+ expected_cols_data = [[row[col_idx] for row in data_rows] for col_idx in range(self.expected_cols)]
1385
+
1386
+ used_cols_set: set[int] = set()
1387
+ col_mapping: dict[int, int] = {} # type: ignore[no-redef]
1388
+
1389
+ for exp_col_idx, expected_col in enumerate(expected_cols_data):
1390
+ best_col_score = 0.0
1391
+ best_col_idx = -1
1392
+
1393
+ for act_col_idx in range(actual_cols):
1394
+ if act_col_idx in used_cols_set:
1395
+ continue
1396
+ # Calculate unordered score (find best row matches)
1397
+ col_score = 0.0
1398
+ used_rows_temp: set[int] = set()
1399
+ for expected_val in expected_col:
1400
+ best_cell_score = 0.0
1401
+ for row_idx in range(1, actual_rows):
1402
+ if row_idx in used_rows_temp:
1403
+ continue
1404
+ actual_val = str(table_array[row_idx, act_col_idx])
1405
+ cell_score = self._cell_score(expected_val, actual_val)
1406
+ if cell_score > best_cell_score:
1407
+ best_cell_score = cell_score
1408
+ col_score += best_cell_score
1409
+
1410
+ if col_score > best_col_score:
1411
+ best_col_score = col_score
1412
+ best_col_idx = act_col_idx
1413
+
1414
+ if best_col_idx >= 0:
1415
+ used_cols_set.add(best_col_idx)
1416
+ col_mapping[exp_col_idx] = best_col_idx
1417
+ total_score += best_col_score
1418
+
1419
+ # Generate row-by-row comparison with best-effort column mapping
1420
+ # Since rows can also shuffle, find best row matches for reporting
1421
+ row_details: list[tuple[int, float, list[str], list[str]]] = [] # type: ignore[no-redef]
1422
+ for exp_row_idx, expected_row in enumerate(data_rows):
1423
+ best_row_score = 0.0
1424
+ best_actual_values: list[str] = [] # type: ignore[no-redef]
1425
+ # Find the best matching actual row
1426
+ for act_row_idx in range(1, actual_rows):
1427
+ row_score = 0.0
1428
+ actual_values: list[str] = [] # type: ignore[no-redef]
1429
+ for col_idx, expected_val in enumerate(expected_row):
1430
+ act_col_idx = col_mapping.get(col_idx, col_idx)
1431
+ if act_col_idx < actual_cols:
1432
+ actual_val = str(table_array[act_row_idx, act_col_idx])
1433
+ else:
1434
+ actual_val = ""
1435
+ actual_values.append(actual_val)
1436
+ row_score += self._cell_score(expected_val, actual_val)
1437
+ if row_score > best_row_score:
1438
+ best_row_score = row_score
1439
+ best_actual_values = actual_values
1440
+ max_row_score = len(expected_row)
1441
+ row_pct = best_row_score / max_row_score if max_row_score > 0 else 0
1442
+ if row_pct < 1.0:
1443
+ row_details.append(
1444
+ (
1445
+ exp_row_idx + 1,
1446
+ row_pct,
1447
+ [str(v) for v in expected_row],
1448
+ best_actual_values,
1449
+ )
1450
+ )
1451
+
1452
+ if row_details:
1453
+ row_details.sort(key=lambda x: x[1]) # worst rows first
1454
+ for r_idx, r_pct, exp_vals, act_vals in row_details[:5]:
1455
+ mismatches.append(f"Row {r_idx}: {r_pct:.0%} | Expected: {exp_vals} | Actual: {act_vals}")
1456
+ return total_score, total_cells, mismatches
1457
+
1458
+ def run(self, content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
1459
+ """Check if expected data values match any table in content."""
1460
+ tables_to_check = self.parsed_tables
1461
+ if tables_to_check is None:
1462
+ tables_to_check = parse_chart_tables(content)
1463
+
1464
+ if not tables_to_check:
1465
+ return False, "No tables found in content", 0.0
1466
+
1467
+ best_score = 0.0
1468
+ best_total = float(len(self.data_rows) * self.expected_cols)
1469
+ best_mismatches: list[str] = []
1470
+
1471
+ for table_data in tables_to_check:
1472
+ # Try both orientations and keep the best score
1473
+ orientations = [table_data.data, table_data.data.T]
1474
+ for data in orientations:
1475
+ score, total, mismatches = self._check_data(data)
1476
+
1477
+ if score == total:
1478
+ return True, f"Data: 100% ({score:.1f}/{total:.0f})", 1.0
1479
+
1480
+ if score > best_score or (score == best_score == 0.0 and not best_mismatches):
1481
+ best_score = score
1482
+ best_total = total
1483
+ best_mismatches = mismatches
1484
+
1485
+ score_pct = (best_score / best_total) * 100 if best_total > 0 else 0
1486
+ score_normalized = score_pct / 100.0
1487
+
1488
+ if score_pct >= 99.5:
1489
+ return (
1490
+ True,
1491
+ f"Data: {score_pct:.1f}% ({best_score:.1f}/{best_total:.0f})",
1492
+ score_normalized,
1493
+ )
1494
+
1495
+ if best_mismatches:
1496
+ return (
1497
+ False,
1498
+ (f"Data: {score_pct:.1f}% ({best_score:.1f}/{best_total:.0f}). {'; '.join(best_mismatches[:3])}"),
1499
+ score_normalized,
1500
+ )
1501
+ return (
1502
+ False,
1503
+ f"Data: {score_pct:.1f}% ({best_score:.1f}/{best_total:.0f})",
1504
+ score_normalized,
1505
+ )
1506
+
1507
+
1508
+ class RotateCheckRule(ParseTestRule):
1509
+ """Test rule that validates the detected original_orientation_angle.
1510
+
1511
+ parse_output and raw_output are set by RuleBasedMetric before calling run().
1512
+ parse_output.layout_pages is the primary source; raw_output.pages is the
1513
+ legacy fallback for historical artifacts.
1514
+ """
1515
+
1516
+ def __init__(self, rule_data: ParseRotateCheckRule | dict):
1517
+ super().__init__(rule_data)
1518
+ rule_data = cast(ParseRotateCheckRule, self._rule_data)
1519
+
1520
+ self.expected_angle = rule_data.value
1521
+ if self.expected_angle is None:
1522
+ raise ValueError("rotate_check rule must have a 'value' field (expected angle)")
1523
+ self.parse_output: ParseOutput | None = None
1524
+ self.raw_output: dict[str, Any] | None = None
1525
+
1526
+ def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str]:
1527
+ actual_angle = self._angle_from_parse_output()
1528
+ if actual_angle is None:
1529
+ actual_angle = self._angle_from_raw_output()
1530
+ if actual_angle is None:
1531
+ return False, "No original_orientation_angle found in output metadata"
1532
+
1533
+ try:
1534
+ if actual_angle == self.expected_angle:
1535
+ return True, ""
1536
+ return False, (f"Expected orientation angle {self.expected_angle}, got {actual_angle}")
1537
+ except Exception as e:
1538
+ return False, f"Error checking orientation angle: {e}"
1539
+
1540
+ def _angle_from_parse_output(self) -> int | float | str | None:
1541
+ if self.parse_output is None:
1542
+ return None
1543
+ if not self.parse_output.layout_pages:
1544
+ return None
1545
+ return self.parse_output.layout_pages[0].original_orientation_angle
1546
+
1547
+ def _angle_from_raw_output(self) -> int | float | str | None:
1548
+ if self.raw_output is None:
1549
+ return None
1550
+ pages = self.raw_output.get("pages")
1551
+ if not isinstance(pages, list) or not pages:
1552
+ return None
1553
+ first_page = pages[0]
1554
+ if not isinstance(first_page, dict):
1555
+ return None
1556
+ return first_page.get("original_orientation_angle")