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,56 @@
|
|
|
1
|
+
"""Judge variant of the rule-based metric with LLM normalization.
|
|
2
|
+
|
|
3
|
+
Extends RuleBasedMetric by post-processing the rule results with LLM
|
|
4
|
+
normalization. Appends ``_judge`` counterparts to ``rule_results`` in the
|
|
5
|
+
returned metadata so the evaluator's per-type loop auto-generates
|
|
6
|
+
``rule_*_judge_pass_rate`` metrics. Also stores ``judge_pass_rate`` in
|
|
7
|
+
metadata so the evaluator can emit ``rule_pass_rate_judge``.
|
|
8
|
+
|
|
9
|
+
Off by default; opt in with ``LLAMACLOUD_BENCH_LLM_NORMALIZATION=judge``.
|
|
10
|
+
When OFF (the default) this class is identical in behaviour to its parent.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from parse_bench.evaluation.metrics.parse.llm_normalization.postprocess import (
|
|
18
|
+
maybe_apply_llm_normalization,
|
|
19
|
+
)
|
|
20
|
+
from parse_bench.evaluation.metrics.parse.rule_based_metric import RuleBasedMetric
|
|
21
|
+
from parse_bench.schemas.evaluation import MetricValue
|
|
22
|
+
|
|
23
|
+
_CHART_TYPES = {"chart_data_point", "chart_data_array_labels", "chart_data_array_data"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RuleBasedJudgeMetric(RuleBasedMetric):
|
|
27
|
+
"""RuleBasedMetric extended with LLM-normalization of chart rule results."""
|
|
28
|
+
|
|
29
|
+
def compute(
|
|
30
|
+
self,
|
|
31
|
+
expected: list[dict[str, Any]] | None, # type: ignore[override]
|
|
32
|
+
actual: str,
|
|
33
|
+
page: int | None = None,
|
|
34
|
+
**kwargs: Any,
|
|
35
|
+
) -> MetricValue:
|
|
36
|
+
result = super().compute(expected, actual, page, **kwargs) # type: ignore[arg-type]
|
|
37
|
+
|
|
38
|
+
rule_results = result.metadata.get("rule_results")
|
|
39
|
+
total = result.metadata.get("total", 0)
|
|
40
|
+
if not rule_results or not total:
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
# ``normalized`` is the same length as ``rule_results``; zip pairs them.
|
|
44
|
+
normalized, llm_metadata = maybe_apply_llm_normalization(rule_results)
|
|
45
|
+
if llm_metadata is None:
|
|
46
|
+
return result # normalization OFF — identical to base class
|
|
47
|
+
|
|
48
|
+
# Snapshot normalized scores before extending rule_results —
|
|
49
|
+
# in BOTH mode, normalized may be the same list object as rule_results.
|
|
50
|
+
result.metadata["judge_pass_rate"] = sum(r["score"] for r in normalized) / total
|
|
51
|
+
result.metadata["judge_passed"] = sum(1 for r in normalized if r.get("passed"))
|
|
52
|
+
|
|
53
|
+
for r, nr in zip(list(rule_results), normalized, strict=False):
|
|
54
|
+
if r.get("type", "") in _CHART_TYPES:
|
|
55
|
+
rule_results.append({**nr, "type": r["type"] + "_judge"})
|
|
56
|
+
return result
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Rule-based metric for executing parse test rules."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import time
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from parse_bench.evaluation.metrics.base import Metric
|
|
10
|
+
from parse_bench.evaluation.metrics.parse.rules_base import RuleNotApplicable
|
|
11
|
+
from parse_bench.evaluation.metrics.parse.test_rules import (
|
|
12
|
+
MissingSpecificWordRule,
|
|
13
|
+
RotateCheckRule,
|
|
14
|
+
WordBagRule,
|
|
15
|
+
create_test_rule,
|
|
16
|
+
)
|
|
17
|
+
from parse_bench.evaluation.metrics.parse.utils import normalize_text
|
|
18
|
+
from parse_bench.schemas.evaluation import MetricValue
|
|
19
|
+
from parse_bench.schemas.parse_output import ParseOutput
|
|
20
|
+
from parse_bench.test_cases.parse_rule_schemas import (
|
|
21
|
+
ParseRuleBase,
|
|
22
|
+
ParseRuleInput,
|
|
23
|
+
get_rule_id,
|
|
24
|
+
get_rule_layout_bindings,
|
|
25
|
+
get_rule_layout_id,
|
|
26
|
+
get_rule_layout_ids,
|
|
27
|
+
get_rule_page,
|
|
28
|
+
get_rule_type,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Per-rule timeout in seconds. Rules that exceed this are marked as failed.
|
|
32
|
+
RULE_TIMEOUT_SECONDS = 120
|
|
33
|
+
|
|
34
|
+
# Fallback for ``_doc_rule_budget_seconds`` when BENCH_DOC_RULE_BUDGET_SECONDS is
|
|
35
|
+
# unset, blank or unparseable.
|
|
36
|
+
DOC_RULE_BUDGET_DEFAULT_SECONDS = 600.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _doc_rule_budget_seconds() -> float:
|
|
40
|
+
"""Cumulative wall-clock budget for ALL rules of a single document.
|
|
41
|
+
|
|
42
|
+
The per-rule ``RULE_TIMEOUT_SECONDS`` alarm bounds one rule, but a document
|
|
43
|
+
with hundreds of rules that each time out can still consume ``n_rules * 120s``
|
|
44
|
+
(hours) and block the whole evaluation pool until the CI job hits its 6h cap.
|
|
45
|
+
This aggregate budget is the backstop: once a document has spent this long on
|
|
46
|
+
rules, the remaining rules are skipped (scored 0, same as a blank output) so
|
|
47
|
+
the eval moves on. One or two pathological documents lose their rule scores
|
|
48
|
+
instead of stalling the entire run.
|
|
49
|
+
|
|
50
|
+
Override with ``BENCH_DOC_RULE_BUDGET_SECONDS`` (0 or negative disables the
|
|
51
|
+
cap). The ``DOC_RULE_BUDGET_DEFAULT_SECONDS`` default bounds a single document
|
|
52
|
+
to roughly ``budget + RULE_TIMEOUT_SECONDS`` in the worst case.
|
|
53
|
+
"""
|
|
54
|
+
raw = os.environ.get("BENCH_DOC_RULE_BUDGET_SECONDS")
|
|
55
|
+
if raw is None or raw.strip() == "":
|
|
56
|
+
return DOC_RULE_BUDGET_DEFAULT_SECONDS
|
|
57
|
+
try:
|
|
58
|
+
return float(raw)
|
|
59
|
+
except ValueError:
|
|
60
|
+
return DOC_RULE_BUDGET_DEFAULT_SECONDS
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _RuleTimeoutError(Exception):
|
|
64
|
+
"""Raised when a single rule exceeds its time budget."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _alarm_handler(signum: int, frame: Any) -> None:
|
|
68
|
+
raise _RuleTimeoutError()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RuleBasedMetric(Metric):
|
|
72
|
+
"""Metric for executing test rules against markdown content."""
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def name(self) -> str:
|
|
76
|
+
"""Return the name of this metric."""
|
|
77
|
+
return "rule_pass_rate"
|
|
78
|
+
|
|
79
|
+
def compute(
|
|
80
|
+
self,
|
|
81
|
+
expected: list[ParseRuleInput] | None,
|
|
82
|
+
actual: str,
|
|
83
|
+
page: int | None = None,
|
|
84
|
+
**kwargs: Any,
|
|
85
|
+
) -> MetricValue:
|
|
86
|
+
"""
|
|
87
|
+
Execute test rules against markdown content.
|
|
88
|
+
|
|
89
|
+
:param expected: List of test rule definitions (from test_rules)
|
|
90
|
+
:param actual: Actual markdown content to test
|
|
91
|
+
:param page: Optional page number (1-indexed) to filter rules
|
|
92
|
+
:param kwargs: Additional parameters (e.g. raw_output for RotateCheckRule)
|
|
93
|
+
:return: MetricValue with pass rate and per-rule results
|
|
94
|
+
"""
|
|
95
|
+
if not expected:
|
|
96
|
+
return MetricValue(
|
|
97
|
+
metric_name=self.name,
|
|
98
|
+
value=1.0, # No rules means pass
|
|
99
|
+
metadata={"note": "No test rules provided"},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Filter rules by page if page is specified
|
|
103
|
+
rules_to_run = expected
|
|
104
|
+
if page is not None:
|
|
105
|
+
# Filter rules that match this page or have no page specified
|
|
106
|
+
rules_to_run = [rule for rule in expected if get_rule_page(rule) is None or get_rule_page(rule) == page]
|
|
107
|
+
|
|
108
|
+
if not rules_to_run:
|
|
109
|
+
return MetricValue(
|
|
110
|
+
metric_name=self.name,
|
|
111
|
+
value=1.0, # No rules for this page means pass
|
|
112
|
+
metadata={"note": f"No test rules for page {page}"},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if not actual:
|
|
116
|
+
# Blank output fails every rule. Emit full per-rule metadata so the
|
|
117
|
+
# judge metric and per-type pass rates include this doc (otherwise
|
|
118
|
+
# blank-output docs silently drop out of the aggregate averages,
|
|
119
|
+
# inflating scores for tools that fail to parse hard documents).
|
|
120
|
+
rule_results = [
|
|
121
|
+
{
|
|
122
|
+
"type": get_rule_type(rule_data),
|
|
123
|
+
"id": rule_data.id if isinstance(rule_data, ParseRuleBase) else get_rule_id(rule_data),
|
|
124
|
+
"page": get_rule_page(rule_data),
|
|
125
|
+
"tags": rule_data.tags if isinstance(rule_data, ParseRuleBase) else [],
|
|
126
|
+
"layout_id": get_rule_layout_id(rule_data),
|
|
127
|
+
"layout_ids": get_rule_layout_ids(rule_data),
|
|
128
|
+
"layout_bindings": get_rule_layout_bindings(rule_data),
|
|
129
|
+
"passed": False,
|
|
130
|
+
"score": 0.0,
|
|
131
|
+
"explanation": "No markdown content provided",
|
|
132
|
+
# rotate_check entries need expected_angle so the per-angle
|
|
133
|
+
# breakdown includes blank-output docs too.
|
|
134
|
+
**(
|
|
135
|
+
{"expected_angle": rule_data.get("value")} if get_rule_type(rule_data) == "rotate_check" else {}
|
|
136
|
+
),
|
|
137
|
+
}
|
|
138
|
+
for rule_data in rules_to_run
|
|
139
|
+
]
|
|
140
|
+
return MetricValue(
|
|
141
|
+
metric_name=self.name,
|
|
142
|
+
value=0.0,
|
|
143
|
+
metadata={
|
|
144
|
+
"note": "No markdown content provided",
|
|
145
|
+
"passed": 0,
|
|
146
|
+
"total": len(rules_to_run),
|
|
147
|
+
"ambiguous_anchor_failures": 0,
|
|
148
|
+
"rule_results": rule_results,
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Execute each rule
|
|
153
|
+
passed = 0
|
|
154
|
+
ambiguous_anchor_failures = 0
|
|
155
|
+
total = len(rules_to_run)
|
|
156
|
+
rule_results = []
|
|
157
|
+
missing_specific_word_cache: tuple[Counter[str], str] | None = None
|
|
158
|
+
|
|
159
|
+
# Timing accumulators
|
|
160
|
+
slow_rules: list[tuple[int, str, float]] = [] # (index, type, seconds)
|
|
161
|
+
timed_out_rules: list[tuple[int, str]] = [] # (index, type)
|
|
162
|
+
|
|
163
|
+
# Aggregate per-document budget: bounds the whole rule loop so a single
|
|
164
|
+
# pathological document cannot consume the entire evaluation.
|
|
165
|
+
doc_budget = _doc_rule_budget_seconds()
|
|
166
|
+
skipped_over_budget = 0
|
|
167
|
+
# Rules that turned out to be undefined for this document: (type, why).
|
|
168
|
+
# These are dropped from rule_results entirely, so they neither pass nor
|
|
169
|
+
# fail and never reach the aggregation.
|
|
170
|
+
skipped_inapplicable: list[tuple[str, str]] = []
|
|
171
|
+
|
|
172
|
+
def _skipped_rule_entry(rule_data: Any, explanation: str) -> dict[str, Any]:
|
|
173
|
+
"""Score a rule 0 without running it (skipped, not evaluated)."""
|
|
174
|
+
return {
|
|
175
|
+
"type": get_rule_type(rule_data),
|
|
176
|
+
"id": rule_data.id if isinstance(rule_data, ParseRuleBase) else get_rule_id(rule_data),
|
|
177
|
+
"page": get_rule_page(rule_data),
|
|
178
|
+
"tags": rule_data.tags if isinstance(rule_data, ParseRuleBase) else [],
|
|
179
|
+
"layout_id": get_rule_layout_id(rule_data),
|
|
180
|
+
"layout_ids": get_rule_layout_ids(rule_data),
|
|
181
|
+
"layout_bindings": get_rule_layout_bindings(rule_data),
|
|
182
|
+
"passed": False,
|
|
183
|
+
"score": 0.0,
|
|
184
|
+
"explanation": explanation,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# Use signal.alarm for per-rule timeout (Unix only, main thread of worker process)
|
|
188
|
+
use_alarm = hasattr(signal, "SIGALRM")
|
|
189
|
+
|
|
190
|
+
# Pre-normalize content ONCE for all rules (major performance optimization).
|
|
191
|
+
# Guard it with the per-rule timeout too: a pathological document (e.g. an
|
|
192
|
+
# O(n^2) regex triggered by degenerate OCR content) must not stall a worker
|
|
193
|
+
# before the rule-loop budget can even take effect. If normalization blows
|
|
194
|
+
# its budget, skip the whole document's rules (scored 0) and move on.
|
|
195
|
+
t_normalize_start = time.monotonic()
|
|
196
|
+
normalize_timed_out = False
|
|
197
|
+
norm_prev_handler = None
|
|
198
|
+
if use_alarm:
|
|
199
|
+
norm_prev_handler = signal.signal(signal.SIGALRM, _alarm_handler)
|
|
200
|
+
signal.alarm(RULE_TIMEOUT_SECONDS)
|
|
201
|
+
try:
|
|
202
|
+
normalized_actual = normalize_text(actual)
|
|
203
|
+
except _RuleTimeoutError:
|
|
204
|
+
normalize_timed_out = True
|
|
205
|
+
normalized_actual = ""
|
|
206
|
+
finally:
|
|
207
|
+
if use_alarm:
|
|
208
|
+
signal.alarm(0)
|
|
209
|
+
signal.signal(signal.SIGALRM, norm_prev_handler)
|
|
210
|
+
t_normalize_elapsed = time.monotonic() - t_normalize_start
|
|
211
|
+
|
|
212
|
+
if normalize_timed_out:
|
|
213
|
+
print(
|
|
214
|
+
f" NORMALIZE TIMEOUT after {t_normalize_elapsed:.1f}s: skipping all {total} rules for this document",
|
|
215
|
+
flush=True,
|
|
216
|
+
)
|
|
217
|
+
explanation = f"Skipped: normalization exceeded {RULE_TIMEOUT_SECONDS}s"
|
|
218
|
+
return MetricValue(
|
|
219
|
+
metric_name=self.name,
|
|
220
|
+
value=0.0,
|
|
221
|
+
metadata={
|
|
222
|
+
"passed": 0,
|
|
223
|
+
"total": total,
|
|
224
|
+
"ambiguous_anchor_failures": 0,
|
|
225
|
+
"skipped_over_budget": total,
|
|
226
|
+
"note": explanation,
|
|
227
|
+
"rule_results": [_skipped_rule_entry(r, explanation) for r in rules_to_run],
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
print(f" Pre-normalized content: {len(actual)} -> {len(normalized_actual)} chars ({t_normalize_elapsed:.1f}s)")
|
|
231
|
+
|
|
232
|
+
# Re-install the per-rule alarm handler for the loop below.
|
|
233
|
+
prev_handler = None
|
|
234
|
+
if use_alarm:
|
|
235
|
+
prev_handler = signal.signal(signal.SIGALRM, _alarm_handler)
|
|
236
|
+
|
|
237
|
+
# Timing baseline for the rule loop (drives the aggregate budget below).
|
|
238
|
+
t_rules_start = time.monotonic()
|
|
239
|
+
|
|
240
|
+
# Log every ~100 rules, but at least first and last
|
|
241
|
+
log_interval = max(total // 10, 100) if total > 10 else total
|
|
242
|
+
try:
|
|
243
|
+
for i, rule_data in enumerate(rules_to_run):
|
|
244
|
+
# Aggregate budget check BEFORE arming the alarm: if this document
|
|
245
|
+
# has already spent its budget, skip every remaining rule (score 0)
|
|
246
|
+
# so one bad document can't stall the whole eval pool.
|
|
247
|
+
if doc_budget > 0 and (time.monotonic() - t_rules_start) > doc_budget:
|
|
248
|
+
remaining = rules_to_run[i:]
|
|
249
|
+
budget_explanation = f"Skipped: document rule budget ({doc_budget:.0f}s) exceeded"
|
|
250
|
+
for remaining_rule in remaining:
|
|
251
|
+
rule_results.append(_skipped_rule_entry(remaining_rule, budget_explanation))
|
|
252
|
+
skipped_over_budget = len(remaining)
|
|
253
|
+
print(
|
|
254
|
+
f" BUDGET EXCEEDED after {time.monotonic() - t_rules_start:.1f}s"
|
|
255
|
+
f" ({doc_budget:.0f}s cap): skipping {skipped_over_budget} of {total} remaining rules",
|
|
256
|
+
flush=True,
|
|
257
|
+
)
|
|
258
|
+
break
|
|
259
|
+
if i == 0 or (i + 1) % log_interval == 0:
|
|
260
|
+
elapsed = time.monotonic() - t_rules_start
|
|
261
|
+
print(f" Processing rule {i + 1}/{total} ({elapsed:.1f}s elapsed)", flush=True)
|
|
262
|
+
rule_id = rule_data.id if isinstance(rule_data, ParseRuleBase) else get_rule_id(rule_data)
|
|
263
|
+
rule_tags = rule_data.tags if isinstance(rule_data, ParseRuleBase) else []
|
|
264
|
+
rule_layout_id = get_rule_layout_id(rule_data)
|
|
265
|
+
rule_layout_ids = get_rule_layout_ids(rule_data)
|
|
266
|
+
rule_layout_bindings = get_rule_layout_bindings(rule_data)
|
|
267
|
+
try:
|
|
268
|
+
t_rule_start = time.monotonic()
|
|
269
|
+
rule_type_name = get_rule_type(rule_data) or "unknown"
|
|
270
|
+
|
|
271
|
+
# Arm the alarm before rule creation + execution
|
|
272
|
+
if use_alarm:
|
|
273
|
+
signal.alarm(RULE_TIMEOUT_SECONDS)
|
|
274
|
+
|
|
275
|
+
rule = create_test_rule(rule_data)
|
|
276
|
+
parse_output = kwargs.get("parse_output")
|
|
277
|
+
if isinstance(parse_output, ParseOutput) and hasattr(rule, "parse_output"):
|
|
278
|
+
rule.parse_output = parse_output
|
|
279
|
+
|
|
280
|
+
if isinstance(rule, RotateCheckRule):
|
|
281
|
+
raw_output = kwargs.get("raw_output")
|
|
282
|
+
if isinstance(raw_output, dict):
|
|
283
|
+
rule.raw_output = raw_output
|
|
284
|
+
if isinstance(rule, MissingSpecificWordRule):
|
|
285
|
+
if missing_specific_word_cache is None:
|
|
286
|
+
missing_specific_word_cache = (
|
|
287
|
+
WordBagRule._extract_normalized_words_static(
|
|
288
|
+
actual,
|
|
289
|
+
include_table_cells=True,
|
|
290
|
+
),
|
|
291
|
+
MissingSpecificWordRule.strip_apostrophes(normalized_actual),
|
|
292
|
+
)
|
|
293
|
+
rule.actual_words = missing_specific_word_cache[0]
|
|
294
|
+
rule.apostrophe_stripped_content = missing_specific_word_cache[1]
|
|
295
|
+
# Pass pre-normalized content to avoid redundant normalization
|
|
296
|
+
result = rule.run(actual, normalized_content=normalized_actual)
|
|
297
|
+
|
|
298
|
+
# Disarm the alarm
|
|
299
|
+
if use_alarm:
|
|
300
|
+
signal.alarm(0)
|
|
301
|
+
|
|
302
|
+
t_rule_elapsed = time.monotonic() - t_rule_start
|
|
303
|
+
if t_rule_elapsed > 2.0:
|
|
304
|
+
slow_rules.append((i, rule_type_name, t_rule_elapsed))
|
|
305
|
+
rule_passed, explanation = result[0], result[1]
|
|
306
|
+
score = result[2] if len(result) == 3 else (1.0 if rule_passed else 0.0)
|
|
307
|
+
rule_result_entry: dict[str, Any] = {
|
|
308
|
+
"type": get_rule_type(rule_data),
|
|
309
|
+
"id": rule_id,
|
|
310
|
+
"page": get_rule_page(rule_data),
|
|
311
|
+
"tags": rule_tags,
|
|
312
|
+
"layout_id": rule_layout_id,
|
|
313
|
+
"layout_ids": rule_layout_ids,
|
|
314
|
+
"layout_bindings": rule_layout_bindings,
|
|
315
|
+
"passed": rule_passed,
|
|
316
|
+
"score": score,
|
|
317
|
+
"explanation": explanation,
|
|
318
|
+
}
|
|
319
|
+
if rule.result_details:
|
|
320
|
+
rule_result_entry["details"] = rule.result_details
|
|
321
|
+
if isinstance(rule, RotateCheckRule):
|
|
322
|
+
rule_result_entry["expected_angle"] = rule.expected_angle
|
|
323
|
+
rule_results.append(rule_result_entry)
|
|
324
|
+
if rule_passed:
|
|
325
|
+
passed += 1
|
|
326
|
+
elif explanation.startswith("[AMBIGUOUS ANCHORS]"):
|
|
327
|
+
ambiguous_anchor_failures += 1
|
|
328
|
+
except RuleNotApplicable as e:
|
|
329
|
+
# The rule carries no evaluable constraint (degenerate rule
|
|
330
|
+
# definition), so the parser had nothing to get right or
|
|
331
|
+
# wrong. Emit NO rule result: that is what keeps it out of
|
|
332
|
+
# the per-type pass rates, the normalized category scores
|
|
333
|
+
# and semantic_formatting. Counting it 0.0 would zero a
|
|
334
|
+
# category on a document that parsed correctly.
|
|
335
|
+
if use_alarm:
|
|
336
|
+
signal.alarm(0)
|
|
337
|
+
skipped_inapplicable.append((rule_type_name, str(e)))
|
|
338
|
+
continue
|
|
339
|
+
except _RuleTimeoutError:
|
|
340
|
+
t_rule_elapsed = time.monotonic() - t_rule_start
|
|
341
|
+
timed_out_rules.append((i, rule_type_name))
|
|
342
|
+
print(
|
|
343
|
+
f" TIMEOUT rule #{i}: type={rule_type_name}"
|
|
344
|
+
f" exceeded {RULE_TIMEOUT_SECONDS}s ({t_rule_elapsed:.1f}s)",
|
|
345
|
+
flush=True,
|
|
346
|
+
)
|
|
347
|
+
rule_results.append(
|
|
348
|
+
{
|
|
349
|
+
"type": get_rule_type(rule_data),
|
|
350
|
+
"id": rule_id,
|
|
351
|
+
"page": get_rule_page(rule_data),
|
|
352
|
+
"tags": rule_tags,
|
|
353
|
+
"layout_id": rule_layout_id,
|
|
354
|
+
"layout_ids": rule_layout_ids,
|
|
355
|
+
"layout_bindings": rule_layout_bindings,
|
|
356
|
+
"passed": False,
|
|
357
|
+
"score": 0.0,
|
|
358
|
+
"explanation": f"Rule timed out after {RULE_TIMEOUT_SECONDS}s",
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
except Exception as e:
|
|
362
|
+
# Disarm the alarm on error
|
|
363
|
+
if use_alarm:
|
|
364
|
+
signal.alarm(0)
|
|
365
|
+
# If rule execution fails, count as failed
|
|
366
|
+
rule_results.append(
|
|
367
|
+
{
|
|
368
|
+
"type": get_rule_type(rule_data),
|
|
369
|
+
"id": rule_id,
|
|
370
|
+
"page": get_rule_page(rule_data),
|
|
371
|
+
"tags": rule_tags,
|
|
372
|
+
"layout_id": rule_layout_id,
|
|
373
|
+
"layout_ids": rule_layout_ids,
|
|
374
|
+
"layout_bindings": rule_layout_bindings,
|
|
375
|
+
"passed": False,
|
|
376
|
+
"score": 0.0,
|
|
377
|
+
"explanation": f"Error executing rule: {e}",
|
|
378
|
+
}
|
|
379
|
+
)
|
|
380
|
+
finally:
|
|
381
|
+
# Always disarm alarm and restore previous handler
|
|
382
|
+
if use_alarm:
|
|
383
|
+
signal.alarm(0)
|
|
384
|
+
if prev_handler is not None:
|
|
385
|
+
signal.signal(signal.SIGALRM, prev_handler)
|
|
386
|
+
|
|
387
|
+
# Inapplicable rules leave the denominator as well as the numerator:
|
|
388
|
+
# they were never evaluated, so they must not dilute the pass rate.
|
|
389
|
+
total -= len(skipped_inapplicable)
|
|
390
|
+
|
|
391
|
+
total_score = 0.0
|
|
392
|
+
for r in rule_results:
|
|
393
|
+
total_score += float(cast(float, r["score"]))
|
|
394
|
+
if total > 0:
|
|
395
|
+
pass_rate = total_score / total
|
|
396
|
+
elif skipped_inapplicable:
|
|
397
|
+
# Every rule for this document was undefined. Same convention as a
|
|
398
|
+
# document with no rules at all: nothing to fail.
|
|
399
|
+
pass_rate = 1.0
|
|
400
|
+
else:
|
|
401
|
+
pass_rate = 0.0
|
|
402
|
+
t_rules_total = time.monotonic() - t_rules_start
|
|
403
|
+
print(
|
|
404
|
+
f" Rules: done, {passed}/{total} passed ({pass_rate:.1%}) in {t_rules_total:.1f}s",
|
|
405
|
+
flush=True,
|
|
406
|
+
)
|
|
407
|
+
if skipped_over_budget:
|
|
408
|
+
print(
|
|
409
|
+
f" SKIPPED {skipped_over_budget} rule(s): document budget ({doc_budget:.0f}s) exceeded",
|
|
410
|
+
flush=True,
|
|
411
|
+
)
|
|
412
|
+
if skipped_inapplicable:
|
|
413
|
+
for rtype, why in skipped_inapplicable:
|
|
414
|
+
print(f" NOT APPLICABLE, excluded from scoring: type={rtype} ({why})", flush=True)
|
|
415
|
+
if timed_out_rules:
|
|
416
|
+
for idx, rtype in timed_out_rules:
|
|
417
|
+
print(f" TIMED OUT rule #{idx}: type={rtype}", flush=True)
|
|
418
|
+
if slow_rules:
|
|
419
|
+
for idx, rtype, secs in slow_rules:
|
|
420
|
+
print(f" slow rule #{idx}: type={rtype} took {secs:.1f}s", flush=True)
|
|
421
|
+
|
|
422
|
+
return MetricValue(
|
|
423
|
+
metric_name=self.name,
|
|
424
|
+
value=pass_rate,
|
|
425
|
+
metadata={
|
|
426
|
+
"passed": passed,
|
|
427
|
+
"total": total,
|
|
428
|
+
"ambiguous_anchor_failures": ambiguous_anchor_failures,
|
|
429
|
+
"skipped_over_budget": skipped_over_budget,
|
|
430
|
+
"skipped_inapplicable": len(skipped_inapplicable),
|
|
431
|
+
"skipped_inapplicable_detail": skipped_inapplicable,
|
|
432
|
+
"rule_results": rule_results,
|
|
433
|
+
},
|
|
434
|
+
)
|