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,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)
|