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,591 @@
1
+ """Diagram rules: score mermaid blocks by graph semantics, never by source text.
2
+
3
+ Three rule types share one pipeline. Every fenced ``mermaid`` block on the page is parsed into
4
+ a :class:`~parse_bench.evaluation.metrics.parse.mermaid_graph.Graph` (labelled nodes,
5
+ labelled/directed edges). The ground truth is the same shape. Nodes are aligned by label with a
6
+ fuzzy ratio (rapidfuzz ``token_set_ratio``, so "Customer submits application form" still meets
7
+ "Submit application") under a one-to-one Hungarian assignment; edges are then compared on the
8
+ aligned ids, honouring direction only when the reference edge is directed.
9
+
10
+ * ``diagram_graph`` — per diagram: a block exists and parses, its type is acceptable, node
11
+ recall/precision and edge F1 clear their thresholds, and the block sits between the text
12
+ anchors. Graded score = mean of the axis scores; ``result_details`` carries both graphs, the
13
+ alignment and every axis so the report can draw them side by side.
14
+ * ``diagram_edge`` — one anchor relation (``edge`` / ``path`` / ``no_edge``) between two labelled
15
+ nodes; robust to however creatively the rest of the diagram was transcribed.
16
+ * ``diagram_count`` — page-level: the expected number of parseable blocks (0 on negatives).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from collections import deque
23
+ from difflib import SequenceMatcher
24
+ from pathlib import Path
25
+ from typing import Any, cast
26
+
27
+ import numpy as np
28
+ from rapidfuzz import fuzz
29
+ from scipy.optimize import linear_sum_assignment
30
+
31
+ from parse_bench.evaluation.metrics.parse.mermaid_graph import (
32
+ Graph,
33
+ extract_mermaid_blocks,
34
+ graph_from_dict,
35
+ parse_mermaid,
36
+ )
37
+ from parse_bench.evaluation.metrics.parse.rules_base import ParseTestRule
38
+ from parse_bench.evaluation.metrics.parse.test_types import TestType
39
+ from parse_bench.evaluation.metrics.parse.utils import normalize_text
40
+ from parse_bench.test_cases.parse_rule_schemas import (
41
+ ParseDiagramCountRule,
42
+ ParseDiagramEdgeRule,
43
+ ParseDiagramGraphRule,
44
+ )
45
+
46
+ # ``graph``/``flowchart`` are one family; org charts and trees are commonly drawn as either.
47
+ _TYPE_ALIASES: dict[str, set[str]] = {
48
+ "flowchart": {"flowchart", "graph"},
49
+ "state": {"state", "statediagram", "statediagram-v2"},
50
+ "sequence": {"sequence", "sequencediagram"},
51
+ "mindmap": {"mindmap"},
52
+ "class": {"class", "classdiagram"},
53
+ "er": {"er", "erdiagram"},
54
+ }
55
+
56
+
57
+ def _canonical_type(name: str) -> str:
58
+ low = name.strip().lower()
59
+ for canon, names in _TYPE_ALIASES.items():
60
+ if low in names:
61
+ return canon
62
+ return low
63
+
64
+
65
+ def norm_label(label: str) -> str:
66
+ """Comparison form of a node/edge label: bench text normalisation + lowercase + no punctuation."""
67
+ s = normalize_text(label or "").lower()
68
+ s = re.sub(r"[^\w\s%/&+-]", " ", s)
69
+ return re.sub(r"\s+", " ", s).strip()
70
+
71
+
72
+ def label_similarity(a: str, b: str) -> float:
73
+ """0–100 similarity tolerant to word order and abbreviation (``token_set_ratio``)."""
74
+ na, nb = norm_label(a), norm_label(b)
75
+ if not na or not nb:
76
+ return 0.0
77
+ if na == nb:
78
+ return 100.0
79
+ # Very short single-word labels ("End", "Yes", "Send") are only a match when identical: any
80
+ # ratio-based score treats a one-letter difference as near-equal, which is exactly wrong here.
81
+ shortest = min(na, nb, key=len)
82
+ if len(shortest) <= 5 and len(shortest.split()) == 1:
83
+ return 100.0 if shortest in (na.split() + nb.split()) and na.split() == nb.split() else 40.0
84
+ score = float(fuzz.token_set_ratio(na, nb))
85
+ # Abbreviations: "Submit application" for "Customer submits application form". Accept the
86
+ # shorter label as a substring match only when it is substantial enough that a stray
87
+ # short word cannot hide inside another ("End" in "Send" must not count).
88
+ shorter = min(na, nb, key=len)
89
+ if len(shorter) >= 8 and len(shorter.split()) >= 2:
90
+ score = max(score, float(fuzz.partial_ratio(na, nb)))
91
+ # Multi-line box labels often come out of the parser with the line breaks dropped and no
92
+ # space in their place ("Office ofState Grand JuryReview"); compare without whitespace too.
93
+ da, db = na.replace(" ", ""), nb.replace(" ", "")
94
+ if len(min(da, db, key=len)) >= 8:
95
+ score = max(score, float(fuzz.ratio(da, db)))
96
+ return score
97
+
98
+
99
+ def page_blocks(md_content: str, page: int | None) -> list[dict[str, Any]]:
100
+ """Mermaid blocks on the rule's page, each with its parsed graph."""
101
+ page_md = _page_markdown(md_content, page)
102
+ blocks = extract_mermaid_blocks(page_md)
103
+ for b in blocks:
104
+ b["graph"] = parse_mermaid(b["source"])
105
+ return blocks
106
+
107
+
108
+ def _page_markdown(md_content: str, page: int | None) -> str:
109
+ if page is None or "\f" not in md_content:
110
+ return md_content
111
+ parts = md_content.split("\f")
112
+ if 1 <= page <= len(parts):
113
+ return parts[page - 1]
114
+ return md_content
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # alignment
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ def align_nodes(expected: Graph, predicted: Graph, threshold: int) -> dict[str, Any]:
123
+ """One-to-one node alignment maximising total label similarity; pairs below ``threshold`` are dropped."""
124
+ exp_ids = list(expected.nodes)
125
+ pred_ids = list(predicted.nodes)
126
+ if not exp_ids or not pred_ids:
127
+ return {"pairs": [], "unmatched_expected": exp_ids, "unmatched_predicted": pred_ids, "matrix": []}
128
+ matrix = np.zeros((len(exp_ids), len(pred_ids)), dtype=float)
129
+ for i, e in enumerate(exp_ids):
130
+ for j, p in enumerate(pred_ids):
131
+ matrix[i, j] = label_similarity(expected.nodes[e].label, predicted.nodes[p].label)
132
+ rows, cols = linear_sum_assignment(-matrix)
133
+ pairs = []
134
+ matched_e: set[str] = set()
135
+ matched_p: set[str] = set()
136
+ for i, j in zip(rows, cols, strict=True):
137
+ if matrix[i, j] >= threshold:
138
+ pairs.append(
139
+ {"expected": exp_ids[i], "predicted": pred_ids[j], "similarity": round(float(matrix[i, j]), 1)}
140
+ )
141
+ matched_e.add(exp_ids[i])
142
+ matched_p.add(pred_ids[j])
143
+ return {
144
+ "pairs": pairs,
145
+ "unmatched_expected": [e for e in exp_ids if e not in matched_e],
146
+ "unmatched_predicted": [p for p in pred_ids if p not in matched_p],
147
+ }
148
+
149
+
150
+ def _edge_key(source: str, target: str, directed: bool) -> tuple[str, str]:
151
+ return (source, target) if directed else tuple(sorted((source, target))) # type: ignore[return-value]
152
+
153
+
154
+ def score_edges(
155
+ expected: Graph,
156
+ predicted: Graph,
157
+ pairs: list[dict[str, Any]],
158
+ label_threshold: int,
159
+ ) -> dict[str, Any]:
160
+ """Edge recall / precision / F1 over the aligned nodes.
161
+
162
+ A reference edge is found when the predicted graph links the two aligned nodes: same
163
+ direction when the reference is directed (a predicted undirected or bidirectional link
164
+ also counts), any direction otherwise. When the reference edge carries text and the
165
+ predicted one too, the texts must fuzzy-match; a predicted edge with no text is accepted
166
+ (LLMs often fold "Yes"/"No" into the target node). Predicted edges between two aligned
167
+ nodes that match no reference edge, and edges touching an unaligned node, are extras.
168
+ """
169
+ e2p = {p["expected"]: p["predicted"] for p in pairs}
170
+ pred_edges = list(predicted.edges)
171
+ used = [False] * len(pred_edges)
172
+ matched: list[dict[str, Any]] = []
173
+ missing: list[dict[str, Any]] = []
174
+ for ref in expected.edges:
175
+ s, t = e2p.get(ref.source), e2p.get(ref.target)
176
+ found = None
177
+ if s is not None and t is not None:
178
+ for k, pe in enumerate(pred_edges):
179
+ if used[k]:
180
+ continue
181
+ forward = pe.source == s and pe.target == t
182
+ backward = pe.source == t and pe.target == s
183
+ if not (forward or backward):
184
+ continue
185
+ if ref.directed and not ref.bidirectional and backward and pe.directed and not pe.bidirectional:
186
+ continue
187
+ if ref.label and pe.label and label_similarity(ref.label, pe.label) < label_threshold:
188
+ continue
189
+ found = k
190
+ break
191
+ entry = {
192
+ "from": ref.source,
193
+ "to": ref.target,
194
+ "label": ref.label,
195
+ "directed": ref.directed,
196
+ "from_label": expected.nodes[ref.source].label,
197
+ "to_label": expected.nodes[ref.target].label,
198
+ }
199
+ if found is None:
200
+ missing.append(entry)
201
+ else:
202
+ used[found] = True
203
+ matched.append(
204
+ {
205
+ **entry,
206
+ "predicted": {
207
+ "from": pred_edges[found].source,
208
+ "to": pred_edges[found].target,
209
+ "label": pred_edges[found].label,
210
+ },
211
+ }
212
+ )
213
+ extra = [
214
+ {
215
+ "from": pe.source,
216
+ "to": pe.target,
217
+ "label": pe.label,
218
+ "from_label": predicted.nodes[pe.source].label,
219
+ "to_label": predicted.nodes[pe.target].label,
220
+ }
221
+ for k, pe in enumerate(pred_edges)
222
+ if not used[k]
223
+ ]
224
+ n_exp, n_pred = len(expected.edges), len(pred_edges)
225
+ recall = len(matched) / n_exp if n_exp else 1.0
226
+ precision = len(matched) / n_pred if n_pred else (1.0 if not n_exp else 0.0)
227
+ f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
228
+ return {
229
+ "recall": round(recall, 4),
230
+ "precision": round(precision, 4),
231
+ "f1": round(f1, 4),
232
+ "matched": matched,
233
+ "missing": missing,
234
+ "extra": extra,
235
+ }
236
+
237
+
238
+ def find_node(graph: Graph, label: str, threshold: int) -> str | None:
239
+ """Best node id for a label, or None when nothing reaches the threshold."""
240
+ best_id, best = None, 0.0
241
+ for node_id, node in graph.nodes.items():
242
+ s = label_similarity(label, node.label)
243
+ if s > best:
244
+ best_id, best = node_id, s
245
+ return best_id if best >= threshold else None
246
+
247
+
248
+ def has_edge(graph: Graph, source: str, target: str, directed: bool, label: str | None, label_threshold: int) -> bool:
249
+ for e in graph.edges:
250
+ forward = e.source == source and e.target == target
251
+ backward = e.source == target and e.target == source
252
+ if not (forward or backward):
253
+ continue
254
+ if directed and backward and e.directed and not e.bidirectional:
255
+ continue
256
+ if label and e.label and label_similarity(label, e.label) < label_threshold:
257
+ continue
258
+ return True
259
+ return False
260
+
261
+
262
+ def has_path(graph: Graph, source: str, target: str, directed: bool) -> bool:
263
+ adj: dict[str, set[str]] = {n: set() for n in graph.nodes}
264
+ for e in graph.edges:
265
+ adj.setdefault(e.source, set()).add(e.target)
266
+ if not directed or not e.directed or e.bidirectional:
267
+ adj.setdefault(e.target, set()).add(e.source)
268
+ seen = {source}
269
+ queue = deque([source])
270
+ while queue:
271
+ cur = queue.popleft()
272
+ if cur == target:
273
+ return True
274
+ for nxt in adj.get(cur, ()):
275
+ if nxt not in seen:
276
+ seen.add(nxt)
277
+ queue.append(nxt)
278
+ return False
279
+
280
+
281
+ def placement(page_md: str, offset: int, text_before: list[str], text_after: list[str]) -> dict[str, Any]:
282
+ """The block must come after the last ``text_before`` line and before the first ``text_after`` line."""
283
+ lowered = page_md.lower()
284
+
285
+ def find(line: str) -> int | None:
286
+ needle = re.sub(r"\s+", " ", line.strip().lower())
287
+ if len(needle) < 4:
288
+ return None
289
+ idx = lowered.find(needle)
290
+ if idx >= 0:
291
+ return idx
292
+ best, best_idx = 0.0, None
293
+ step = max(1, len(needle) // 4)
294
+ for i in range(0, max(1, len(lowered) - len(needle)), step):
295
+ r = SequenceMatcher(None, needle, lowered[i : i + len(needle)]).ratio()
296
+ if r > best:
297
+ best, best_idx = r, i
298
+ return best_idx if best >= 0.8 else None
299
+
300
+ before_idx = [i for i in (find(t) for t in text_before) if i is not None]
301
+ after_idx = [i for i in (find(t) for t in text_after) if i is not None]
302
+ if not before_idx and not after_idx:
303
+ return {"passed": None, "score": None, "reason": "anchors not found in markdown"}
304
+ ok = not (before_idx and offset < max(before_idx)) and not (after_idx and offset > min(after_idx))
305
+ return {
306
+ "passed": ok,
307
+ "score": 1.0 if ok else 0.0,
308
+ "block_offset": offset,
309
+ "before_offset": max(before_idx) if before_idx else None,
310
+ "after_offset": min(after_idx) if after_idx else None,
311
+ }
312
+
313
+
314
+ def node_leakage(page_md: str, expected: Graph, min_words: int = 2) -> dict[str, Any]:
315
+ """Share of multi-word node labels that also appear as plain text outside any mermaid block.
316
+
317
+ Informational in v0.1 (``passed`` is None): a caption or a legend may legitimately repeat a
318
+ label, so the threshold is calibrated from data before it can fail a page.
319
+ """
320
+ stripped = re.sub(r"```.*?```", " ", page_md, flags=re.S)
321
+ haystack = norm_label(stripped)
322
+ labels = [n.label for n in expected.nodes.values() if len(n.label.split()) >= min_words]
323
+ if not labels:
324
+ return {"passed": None, "score": None, "reason": "no multi-word labels"}
325
+ leaked = [lab for lab in labels if norm_label(lab) and norm_label(lab) in haystack]
326
+ return {"passed": None, "score": None, "leaked": leaked, "ratio": round(len(leaked) / len(labels), 3)}
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # rules
331
+ # ---------------------------------------------------------------------------
332
+
333
+
334
+ class DiagramGraphRule(ParseTestRule):
335
+ """One expected diagram, scored as a graph against the best mermaid block on the page."""
336
+
337
+ def __init__(self, rule_data: ParseDiagramGraphRule | dict):
338
+ super().__init__(rule_data)
339
+ if self.type != TestType.DIAGRAM_GRAPH.value:
340
+ raise ValueError(f"Invalid type for DiagramGraphRule: {self.type}")
341
+ self.rule = cast(ParseDiagramGraphRule, self._rule_data)
342
+ self.expected = graph_from_dict(self.rule.graph)
343
+ self.raw_output: dict[str, Any] | None = None
344
+ self.source_file_path: str | None = None
345
+ self.test_case_path: str | None = None
346
+
347
+ def _reference_path(self) -> Path | None:
348
+ if not self.rule.reference_image:
349
+ return None
350
+ for p in (self.test_case_path, self.source_file_path):
351
+ if p:
352
+ cand = Path(p).parent / self.rule.reference_image
353
+ if cand.exists():
354
+ return cand
355
+ return None
356
+
357
+ def _best_block(self, blocks: list[dict[str, Any]]) -> tuple[dict[str, Any], dict[str, Any]] | None:
358
+ """The block whose node alignment covers most of the reference (ties: most similar)."""
359
+ best: tuple[dict[str, Any], dict[str, Any]] | None = None
360
+ best_key = (-1, -1.0)
361
+ for b in blocks:
362
+ g: Graph = b["graph"]
363
+ if g.is_empty:
364
+ continue
365
+ al = align_nodes(self.expected, g, self.rule.node_match_threshold)
366
+ key = (len(al["pairs"]), sum(p["similarity"] for p in al["pairs"]))
367
+ if key > best_key:
368
+ best_key, best = key, (b, al)
369
+ return best
370
+
371
+ def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
372
+ page_md = _page_markdown(md_content, self.page)
373
+ blocks = page_blocks(md_content, self.page)
374
+ ref_path = self._reference_path()
375
+ details: dict[str, Any] = {
376
+ "expected": {
377
+ "graph": self.expected.to_dict(),
378
+ "diagram_class": self.rule.diagram_class,
379
+ "accepted_types": self.rule.accepted_types,
380
+ "bbox": self.rule.bbox,
381
+ "caption": self.rule.caption,
382
+ "reference_image": self.rule.reference_image,
383
+ "text_before": self.rule.text_before,
384
+ "text_after": self.rule.text_after,
385
+ "mermaid": graph_to_mermaid(self.expected),
386
+ },
387
+ "blocks_on_page": [
388
+ {
389
+ "type": b["graph"].type,
390
+ "nodes": len(b["graph"].nodes),
391
+ "edges": len(b["graph"].edges),
392
+ "errors": b["graph"].errors[:5],
393
+ }
394
+ for b in blocks
395
+ ],
396
+ "axes": {},
397
+ }
398
+ if ref_path is not None:
399
+ from parse_bench.evaluation.metrics.parse.rules_image import thumbnail_data_uri
400
+
401
+ details["expected"]["thumb"] = thumbnail_data_uri(ref_path)
402
+ self.result_details = details
403
+ axes = details["axes"]
404
+
405
+ if not blocks:
406
+ axes["present"] = {"passed": False, "score": 0.0, "reason": "no mermaid block on page"}
407
+ return False, "No mermaid block on the page for the expected diagram", 0.0
408
+ best = self._best_block(blocks)
409
+ if best is None:
410
+ axes["present"] = {
411
+ "passed": False,
412
+ "score": 0.0,
413
+ "reason": "mermaid block(s) present but none parses into a graph",
414
+ }
415
+ details["predicted"] = {"source": blocks[0]["source"], "errors": blocks[0]["graph"].errors[:10]}
416
+ return False, "Mermaid block does not parse into any node", 0.0
417
+ block, alignment = best
418
+ graph: Graph = block["graph"]
419
+ details["predicted"] = {"graph": graph.to_dict(), "source": block["source"], "offset": block["offset"]}
420
+ details["alignment"] = alignment
421
+ axes["present"] = {"passed": True, "score": 1.0}
422
+
423
+ accepted = {_canonical_type(t) for t in self.rule.accepted_types}
424
+ type_ok = _canonical_type(graph.type) in accepted
425
+ axes["type"] = {
426
+ "passed": type_ok,
427
+ "score": 1.0 if type_ok else 0.0,
428
+ "predicted": graph.type,
429
+ "accepted": sorted(accepted),
430
+ }
431
+
432
+ n_exp, n_pred, n_match = len(self.expected.nodes), len(graph.nodes), len(alignment["pairs"])
433
+ recall = n_match / n_exp if n_exp else 1.0
434
+ precision = n_match / n_pred if n_pred else 0.0
435
+ node_f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
436
+ nodes_ok = recall >= self.rule.node_recall_threshold and precision >= self.rule.node_precision_threshold
437
+ axes["nodes"] = {
438
+ "passed": nodes_ok,
439
+ "score": round(node_f1, 4),
440
+ "recall": round(recall, 4),
441
+ "precision": round(precision, 4),
442
+ "matched": n_match,
443
+ "expected": n_exp,
444
+ "predicted": n_pred,
445
+ }
446
+
447
+ edges = score_edges(self.expected, graph, alignment["pairs"], self.rule.edge_label_threshold)
448
+ axes["edges"] = {"passed": edges["f1"] >= self.rule.edge_f1_threshold, "score": edges["f1"], **edges}
449
+
450
+ axes["placement"] = placement(page_md, block["offset"], self.rule.text_before, self.rule.text_after)
451
+ details["leakage"] = node_leakage(page_md, self.expected)
452
+
453
+ applicable = [a for a in axes.values() if a.get("passed") is not None]
454
+ passed = all(a["passed"] for a in applicable)
455
+ score = float(np.mean([a["score"] for a in applicable])) if applicable else 0.0
456
+ failed = [k for k, a in axes.items() if a.get("passed") is False]
457
+ expl = f"{graph.type}: nodes {n_match}/{n_exp} (P {precision:.2f}), edges F1 {edges['f1']:.2f}" + (
458
+ f"; failed: {', '.join(failed)}" if failed else "; all axes pass"
459
+ )
460
+ return passed, expl, round(score, 4)
461
+
462
+
463
+ class DiagramEdgeRule(ParseTestRule):
464
+ """One anchor relation between two labelled nodes, checked in any mermaid block on the page."""
465
+
466
+ def __init__(self, rule_data: ParseDiagramEdgeRule | dict):
467
+ super().__init__(rule_data)
468
+ if self.type != TestType.DIAGRAM_EDGE.value:
469
+ raise ValueError(f"Invalid type for DiagramEdgeRule: {self.type}")
470
+ self.rule = cast(ParseDiagramEdgeRule, self._rule_data)
471
+
472
+ def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
473
+ blocks = [b for b in page_blocks(md_content, self.page) if not b["graph"].is_empty]
474
+ r = self.rule
475
+ self.result_details = {
476
+ "source": r.source,
477
+ "target": r.target,
478
+ "relation": r.relation,
479
+ "label": r.label,
480
+ "checks": [],
481
+ }
482
+ if not blocks:
483
+ if r.relation == "no_edge":
484
+ return False, "No mermaid block on the page (no_edge needs a diagram to hold)", 0.0
485
+ return False, "No parseable mermaid block on the page", 0.0
486
+ for b in blocks:
487
+ g: Graph = b["graph"]
488
+ s = find_node(g, r.source, r.node_match_threshold)
489
+ t = find_node(g, r.target, r.node_match_threshold)
490
+ check: dict[str, Any] = {
491
+ "source_node": g.nodes[s].label if s else None,
492
+ "target_node": g.nodes[t].label if t else None,
493
+ }
494
+ self.result_details["checks"].append(check)
495
+ if s is None or t is None:
496
+ check["result"] = "node(s) not found"
497
+ if r.relation == "no_edge" and (s is None) != (t is None):
498
+ # One endpoint exists, the other does not: there is certainly no edge between them.
499
+ return True, f"no_edge holds: `{r.source if s is None else r.target}` absent", 1.0
500
+ continue
501
+ if r.relation == "edge":
502
+ ok = has_edge(g, s, t, r.directed, r.label, r.edge_label_threshold)
503
+ elif r.relation == "path":
504
+ ok = has_path(g, s, t, r.directed)
505
+ else:
506
+ ok = not has_edge(g, s, t, r.directed, None, r.edge_label_threshold)
507
+ check["result"] = "holds" if ok else "violated"
508
+ if ok:
509
+ return True, f"{r.relation} `{r.source}` → `{r.target}` holds", 1.0
510
+ last = self.result_details["checks"][-1]
511
+ if last.get("result") == "node(s) not found":
512
+ missing = [
513
+ lab for lab, node in ((r.source, last["source_node"]), (r.target, last["target_node"])) if node is None
514
+ ]
515
+ return False, f"node(s) not found in any diagram: {', '.join(f'`{m}`' for m in missing)}", 0.0
516
+ return False, f"{r.relation} `{r.source}` → `{r.target}` violated", 0.0
517
+
518
+
519
+ class DiagramCountRule(ParseTestRule):
520
+ """Page-level count of parseable mermaid blocks (0 on negative pages)."""
521
+
522
+ def __init__(self, rule_data: ParseDiagramCountRule | dict):
523
+ super().__init__(rule_data)
524
+ if self.type != TestType.DIAGRAM_COUNT.value:
525
+ raise ValueError(f"Invalid type for DiagramCountRule: {self.type}")
526
+ self.rule = cast(ParseDiagramCountRule, self._rule_data)
527
+
528
+ def run(self, md_content: str, normalized_content: str | None = None) -> tuple[bool, str, float]:
529
+ blocks = page_blocks(md_content, self.page)
530
+ parseable = [b for b in blocks if not b["graph"].is_empty]
531
+ broken = [b for b in blocks if b["graph"].is_empty]
532
+ expected = max(0, int(self.rule.expected_count))
533
+ self.result_details = {
534
+ "expected_count": expected,
535
+ "strict": self.rule.strict,
536
+ "blocks": [
537
+ {
538
+ "type": b["graph"].type,
539
+ "nodes": len(b["graph"].nodes),
540
+ "edges": len(b["graph"].edges),
541
+ "parseable": not b["graph"].is_empty,
542
+ "errors": b["graph"].errors[:5],
543
+ }
544
+ for b in blocks
545
+ ],
546
+ "parseable": len(parseable),
547
+ "broken": len(broken),
548
+ }
549
+ problems: list[str] = []
550
+ if broken:
551
+ problems.append(f"{len(broken)} mermaid block(s) do not parse")
552
+ extra = len(parseable) - expected
553
+ if expected == 0 and parseable:
554
+ problems.append(f"{len(parseable)} mermaid block(s) on a page that should have none")
555
+ elif self.rule.strict and extra > 0:
556
+ problems.append(f"{extra} more mermaid block(s) than the {expected} expected")
557
+ if problems:
558
+ offending = len(broken) + (max(0, extra) if (self.rule.strict or expected == 0) else 0)
559
+ score = max(0.0, 1.0 - offending / max(1, expected + offending))
560
+ return False, "; ".join(problems), round(score, 4)
561
+ return True, f"{len(parseable)} parseable mermaid block(s) for {expected} expected", 1.0
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # rendering the reference for reports
566
+ # ---------------------------------------------------------------------------
567
+
568
+
569
+ def _mermaid_escape(label: str) -> str:
570
+ return '"' + label.replace('"', "#quot;") + '"'
571
+
572
+
573
+ def graph_to_mermaid(graph: Graph) -> str:
574
+ """Render a reference graph as a flowchart so the report can draw GT next to the prediction."""
575
+ ids = {node_id: f"n{i}" for i, node_id in enumerate(graph.nodes)}
576
+ lines = ["flowchart TD"]
577
+ by_group: dict[str | None, list[str]] = {}
578
+ for node_id, node in graph.nodes.items():
579
+ by_group.setdefault(node.group, []).append(f"{ids[node_id]}[{_mermaid_escape(node.label)}]")
580
+ for group, decls in by_group.items():
581
+ if group is None:
582
+ lines.extend(f" {d}" for d in decls)
583
+ else:
584
+ lines.append(f" subgraph {_mermaid_escape(graph.groups.get(group, group))}")
585
+ lines.extend(f" {d}" for d in decls)
586
+ lines.append(" end")
587
+ for e in graph.edges:
588
+ arrow = "<-->" if e.bidirectional else ("-->" if e.directed else "---")
589
+ text = f"|{_mermaid_escape(e.label)}|" if e.label else ""
590
+ lines.append(f" {ids[e.source]} {arrow}{text} {ids[e.target]}")
591
+ return "\n".join(lines)