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