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.
- parse_bench/__init__.py +3 -0
- parse_bench/analysis/__init__.py +6 -0
- parse_bench/analysis/aggregation_report.py +582 -0
- parse_bench/analysis/cli.py +472 -0
- parse_bench/analysis/comparison.py +382 -0
- parse_bench/analysis/comparison_core.py +357 -0
- parse_bench/analysis/comparison_report.py +2066 -0
- parse_bench/analysis/detailed_report.py +2254 -0
- parse_bench/analysis/leaderboard_report.py +852 -0
- parse_bench/analysis/metric_definitions.py +771 -0
- parse_bench/cli.py +267 -0
- parse_bench/data/__init__.py +1 -0
- parse_bench/data/cli.py +118 -0
- parse_bench/data/download.py +127 -0
- parse_bench/evaluation/__init__.py +11 -0
- parse_bench/evaluation/cli.py +435 -0
- parse_bench/evaluation/evaluators/__init__.py +17 -0
- parse_bench/evaluation/evaluators/base.py +34 -0
- parse_bench/evaluation/evaluators/extract.py +429 -0
- parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
- parse_bench/evaluation/evaluators/parse.py +1353 -0
- parse_bench/evaluation/evaluators/qa.py +199 -0
- parse_bench/evaluation/layout_adapters/__init__.py +21 -0
- parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
- parse_bench/evaluation/layout_adapters/base.py +105 -0
- parse_bench/evaluation/layout_adapters/registry.py +109 -0
- parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
- parse_bench/evaluation/layout_label_mappers/base.py +66 -0
- parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
- parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
- parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
- parse_bench/evaluation/metric_aggregation.py +56 -0
- parse_bench/evaluation/metrics/__init__.py +5 -0
- parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
- parse_bench/evaluation/metrics/attribution/constants.py +12 -0
- parse_bench/evaluation/metrics/attribution/core.py +1108 -0
- parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
- parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
- parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
- parse_bench/evaluation/metrics/base.py +33 -0
- parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
- parse_bench/evaluation/metrics/extract/__init__.py +29 -0
- parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
- parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
- parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
- parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
- parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
- parse_bench/evaluation/metrics/extract/test_types.py +11 -0
- parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
- parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
- parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
- parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
- parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
- parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
- parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
- parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
- parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
- parse_bench/evaluation/metrics/parse/__init__.py +5 -0
- parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
- parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
- parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
- parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
- parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
- parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
- parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
- parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
- parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
- parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
- parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
- parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
- parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
- parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
- parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
- parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
- parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
- parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
- parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
- parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
- parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
- parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
- parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
- parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
- parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
- parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
- parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
- parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
- parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
- parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
- parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
- parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
- parse_bench/evaluation/metrics/parse/test_types.py +103 -0
- parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
- parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
- parse_bench/evaluation/metrics/parse/utils.py +885 -0
- parse_bench/evaluation/metrics/qa/__init__.py +5 -0
- parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
- parse_bench/evaluation/qa/__init__.py +5 -0
- parse_bench/evaluation/qa/llm_service.py +335 -0
- parse_bench/evaluation/reports/__init__.py +8 -0
- parse_bench/evaluation/reports/csv.py +64 -0
- parse_bench/evaluation/reports/html.py +338 -0
- parse_bench/evaluation/reports/markdown.py +98 -0
- parse_bench/evaluation/reports/rule_csv.py +22 -0
- parse_bench/evaluation/runner.py +1864 -0
- parse_bench/evaluation/stats.py +104 -0
- parse_bench/extensions.py +72 -0
- parse_bench/inference/__init__.py +33 -0
- parse_bench/inference/chunkr_layout_extraction.py +160 -0
- parse_bench/inference/cli.py +484 -0
- parse_bench/inference/layout_extraction.py +422 -0
- parse_bench/inference/pipelines/__init__.py +59 -0
- parse_bench/inference/pipelines/extract.py +39 -0
- parse_bench/inference/pipelines/layout.py +142 -0
- parse_bench/inference/pipelines/parse.py +2603 -0
- parse_bench/inference/pipelines.py +0 -0
- parse_bench/inference/providers/__init__.py +28 -0
- parse_bench/inference/providers/base.py +196 -0
- parse_bench/inference/providers/cancellation.py +137 -0
- parse_bench/inference/providers/extract/__init__.py +22 -0
- parse_bench/inference/providers/extract/citations.py +549 -0
- parse_bench/inference/providers/extract/extend.py +851 -0
- parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
- parse_bench/inference/providers/layoutdet/__init__.py +25 -0
- parse_bench/inference/providers/layoutdet/adapters.py +946 -0
- parse_bench/inference/providers/layoutdet/base.py +203 -0
- parse_bench/inference/providers/layoutdet/chandra.py +449 -0
- parse_bench/inference/providers/layoutdet/docling.py +125 -0
- parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
- parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
- parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
- parse_bench/inference/providers/layoutdet/paddle.py +117 -0
- parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
- parse_bench/inference/providers/layoutdet/surya.py +250 -0
- parse_bench/inference/providers/layoutdet/yolo.py +109 -0
- parse_bench/inference/providers/parse/__init__.py +64 -0
- parse_bench/inference/providers/parse/_docling_common.py +233 -0
- parse_bench/inference/providers/parse/_layout_utils.py +611 -0
- parse_bench/inference/providers/parse/amazon_nova.py +515 -0
- parse_bench/inference/providers/parse/anthropic.py +882 -0
- parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
- parse_bench/inference/providers/parse/chandra2.py +633 -0
- parse_bench/inference/providers/parse/chunkr.py +268 -0
- parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
- parse_bench/inference/providers/parse/datalab.py +370 -0
- parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
- parse_bench/inference/providers/parse/docling.py +281 -0
- parse_bench/inference/providers/parse/docling_serve.py +289 -0
- parse_bench/inference/providers/parse/dots_ocr.py +574 -0
- parse_bench/inference/providers/parse/extend_parse.py +710 -0
- parse_bench/inference/providers/parse/falconocr.py +436 -0
- parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
- parse_bench/inference/providers/parse/gemma4.py +472 -0
- parse_bench/inference/providers/parse/glm_zai.py +229 -0
- parse_bench/inference/providers/parse/google.py +1125 -0
- parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
- parse_bench/inference/providers/parse/google_docai.py +776 -0
- parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
- parse_bench/inference/providers/parse/granite_vision.py +515 -0
- parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
- parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
- parse_bench/inference/providers/parse/landingai.py +452 -0
- parse_bench/inference/providers/parse/liteparse.py +350 -0
- parse_bench/inference/providers/parse/llamaparse.py +677 -0
- parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
- parse_bench/inference/providers/parse/markitdown.py +138 -0
- parse_bench/inference/providers/parse/mineru25.py +405 -0
- parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
- parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
- parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
- parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
- parse_bench/inference/providers/parse/oi_parser.py +222 -0
- parse_bench/inference/providers/parse/openai.py +740 -0
- parse_bench/inference/providers/parse/opendataloader.py +152 -0
- parse_bench/inference/providers/parse/paddleocr.py +624 -0
- parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
- parse_bench/inference/providers/parse/pulse.py +785 -0
- parse_bench/inference/providers/parse/pymupdf.py +207 -0
- parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
- parse_bench/inference/providers/parse/pypdf.py +179 -0
- parse_bench/inference/providers/parse/qwen.py +678 -0
- parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
- parse_bench/inference/providers/parse/reducto.py +546 -0
- parse_bench/inference/providers/parse/surya2.py +372 -0
- parse_bench/inference/providers/parse/tesseract.py +301 -0
- parse_bench/inference/providers/parse/textract.py +694 -0
- parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
- parse_bench/inference/providers/parse/unstructured.py +485 -0
- parse_bench/inference/providers/parse/warp_ingest.py +199 -0
- parse_bench/inference/providers/registry.py +49 -0
- parse_bench/inference/renormalize.py +170 -0
- parse_bench/inference/runner.py +2023 -0
- parse_bench/layout_label_mapping.py +424 -0
- parse_bench/layout_projection.py +179 -0
- parse_bench/pipeline/__init__.py +1 -0
- parse_bench/pipeline/cli.py +549 -0
- parse_bench/schemas/__init__.py +33 -0
- parse_bench/schemas/evaluation.py +93 -0
- parse_bench/schemas/extract_output.py +36 -0
- parse_bench/schemas/layout_detection_output.py +545 -0
- parse_bench/schemas/layout_ontology.py +315 -0
- parse_bench/schemas/metrics.py +69 -0
- parse_bench/schemas/parse_output.py +152 -0
- parse_bench/schemas/pipeline.py +22 -0
- parse_bench/schemas/pipeline_io.py +106 -0
- parse_bench/schemas/product.py +97 -0
- parse_bench/test_cases/__init__.py +25 -0
- parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
- parse_bench/test_cases/extract_field_paths.py +164 -0
- parse_bench/test_cases/layout_attribution_generation.py +287 -0
- parse_bench/test_cases/loader.py +652 -0
- parse_bench/test_cases/parse_rule_schemas.py +1071 -0
- parse_bench/test_cases/rule_filters.py +32 -0
- parse_bench/test_cases/rule_ids.py +107 -0
- parse_bench/test_cases/schema.py +427 -0
- parse_bench/utils/__init__.py +15 -0
- parse_bench/utils/gemini_layout_utils.py +670 -0
- parse_bench/utils/text_aggregation.py +100 -0
- parse_bench-1.0.0.dist-info/METADATA +476 -0
- parse_bench-1.0.0.dist-info/RECORD +227 -0
- parse_bench-1.0.0.dist-info/WHEEL +4 -0
- parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
- 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")
|