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,1071 @@
1
+ """Pydantic schemas for parse rule payloads.
2
+
3
+ The parse rule pipeline uses loosely typed dictionaries today. These models add
4
+ structured validation while keeping compatibility with legacy payloads via
5
+ `extra="allow"`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Literal
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
13
+
14
+ from parse_bench.evaluation.metrics.parse.test_types import TestType
15
+
16
+
17
+ class DictCompatPydanticModel(BaseModel):
18
+ """Base model with a light dict-like `get` interface."""
19
+
20
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
21
+
22
+ def get(self, key: str, default: Any = None) -> Any:
23
+ # O(1) lookup: check field names, extra fields, then aliases
24
+ cls_fields = type(self).model_fields
25
+ if key in cls_fields or (self.model_extra and key in self.model_extra):
26
+ return getattr(self, key)
27
+ # Check aliases (e.g. "_csv_path" → csv_path)
28
+ for field_name, field_info in cls_fields.items():
29
+ if field_info.alias == key:
30
+ return getattr(self, field_name)
31
+ return default
32
+
33
+
34
+ class ParseRuleBase(DictCompatPydanticModel):
35
+ """Base schema shared by all typed parse rules.
36
+
37
+ `id` is intentionally optional so existing datasets without rule ids keep
38
+ loading cleanly while new ids can be added incrementally.
39
+ """
40
+
41
+ type: str
42
+ id: str | None = Field(
43
+ default=None,
44
+ description="Optional stable identifier for the rule",
45
+ )
46
+ page: int | None = Field(default=None)
47
+ max_diffs: int | float = Field(default=0, description="Allowed Levenshtein distance")
48
+ tags: list[str] = Field(default_factory=list)
49
+ layout_id: str | None = Field(
50
+ default=None,
51
+ description="Primary linked layout element id for visual grounding.",
52
+ )
53
+ layout_ids: list[str] = Field(
54
+ default_factory=list,
55
+ description="All linked layout element ids for visual grounding.",
56
+ )
57
+ layout_bindings: dict[str, str | list[str]] = Field(
58
+ default_factory=dict,
59
+ description="Optional role-based grounding map (e.g. before/after for order rules).",
60
+ )
61
+
62
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
63
+
64
+ @model_validator(mode="after")
65
+ def normalize_layout_grounding(self) -> ParseRuleBase:
66
+ """Normalize grounding fields into a consistent, deduped representation."""
67
+
68
+ ordered_ids: list[str] = []
69
+ if self.layout_id:
70
+ ordered_ids.append(self.layout_id)
71
+
72
+ if self.layout_ids:
73
+ ordered_ids.extend(layout_id for layout_id in self.layout_ids if isinstance(layout_id, str))
74
+
75
+ if self.layout_bindings:
76
+ for value in self.layout_bindings.values():
77
+ if isinstance(value, str):
78
+ ordered_ids.append(value)
79
+ elif isinstance(value, list):
80
+ ordered_ids.extend(v for v in value if isinstance(v, str))
81
+
82
+ deduped_ids: list[str] = []
83
+ seen: set[str] = set()
84
+ for layout_id in ordered_ids:
85
+ if layout_id and layout_id not in seen:
86
+ seen.add(layout_id)
87
+ deduped_ids.append(layout_id)
88
+
89
+ self.layout_ids = deduped_ids
90
+ if self.layout_id is None and self.layout_ids:
91
+ self.layout_id = self.layout_ids[0]
92
+ return self
93
+
94
+
95
+ class _HasAnchorCells(BaseModel):
96
+ """Shared table anchor cells field."""
97
+
98
+ table_anchor_cells: list[Any] | None = Field(default_factory=list)
99
+ model_config = ConfigDict(extra="allow")
100
+
101
+ @field_validator("table_anchor_cells", mode="before")
102
+ @classmethod
103
+ def _coerce_null_to_list(cls, v: Any) -> list[Any]:
104
+ return v if v is not None else []
105
+
106
+
107
+ class ParsePresenceRule(ParseRuleBase):
108
+ """Schema for `present` and `absent` rules."""
109
+
110
+ type: Literal[TestType.PRESENT.value, TestType.ABSENT.value]
111
+ text: str = ""
112
+ keep_formatting_text_normalisation: bool = False
113
+ case_sensitive: bool = True
114
+ first_n: int | None = None
115
+ last_n: int | None = None
116
+ count: int | None = None
117
+
118
+
119
+ class _SentenceBagRule(ParseRuleBase):
120
+ bag_of_sentence: dict[str, int] = Field(default_factory=dict)
121
+
122
+
123
+ class ParseUnexpectedSentenceRule(_SentenceBagRule):
124
+ type: Literal[TestType.UNEXPECTED_SENTENCE.value]
125
+
126
+
127
+ class ParseUnexpectedSentencePercentRule(_SentenceBagRule):
128
+ type: Literal[TestType.UNEXPECTED_SENTENCE_PERCENT.value]
129
+ original_md: str | None = Field(
130
+ default=None,
131
+ description=(
132
+ "Original ground-truth markdown; when provided, sentences found in this text are not counted as unexpected."
133
+ ),
134
+ )
135
+
136
+
137
+ class ParseTooManySentenceOccurrenceRule(_SentenceBagRule):
138
+ type: Literal[TestType.TOO_MANY_SENTENCE_OCCURENCE.value]
139
+
140
+
141
+ class ParseTooManySentenceOccurrencePercentRule(_SentenceBagRule):
142
+ type: Literal[TestType.TOO_MANY_SENTENCE_OCCURENCE_PERCENT.value]
143
+
144
+
145
+ class ParseMissingSentenceRule(_SentenceBagRule):
146
+ type: Literal[TestType.MISSING_SENTENCE.value]
147
+
148
+
149
+ class ParseMissingSentencePercentRule(_SentenceBagRule):
150
+ type: Literal[TestType.MISSING_SENTENCE_PERCENT.value]
151
+
152
+
153
+ class ParseMissingSpecificSentenceRule(ParseRuleBase):
154
+ """Check that a specific sentence is present (fail if missing)."""
155
+
156
+ type: Literal[TestType.MISSING_SPECIFIC_SENTENCE.value]
157
+ sentence: str = Field(description="The specific sentence to look for.")
158
+
159
+
160
+ class _WordBagRule(ParseRuleBase):
161
+ bag_of_word: dict[str, int] = Field(default_factory=dict)
162
+
163
+
164
+ class ParseUnexpectedWordRule(_WordBagRule):
165
+ type: Literal[TestType.UNEXPECTED_WORD.value]
166
+
167
+
168
+ class ParseUnexpectedWordPercentRule(_WordBagRule):
169
+ type: Literal[TestType.UNEXPECTED_WORD_PERCENT.value]
170
+
171
+
172
+ class ParseTooManyWordOccurrenceRule(_WordBagRule):
173
+ type: Literal[TestType.TOO_MANY_WORD_OCCURENCE.value]
174
+
175
+
176
+ class ParseTooManyWordOccurrencePercentRule(_WordBagRule):
177
+ type: Literal[TestType.TOO_MANY_WORD_OCCURENCE_PERCENT.value]
178
+
179
+
180
+ class ParseMissingWordRule(_WordBagRule):
181
+ type: Literal[TestType.MISSING_WORD.value]
182
+
183
+
184
+ class ParseMissingWordPercentRule(_WordBagRule):
185
+ type: Literal[TestType.MISSING_WORD_PERCENT.value]
186
+
187
+
188
+ class ParseMissingSpecificWordRule(ParseRuleBase):
189
+ """Check that a specific word is present (fail if missing)."""
190
+
191
+ type: Literal[TestType.MISSING_SPECIFIC_WORD.value]
192
+ word: str = Field(description="The specific word to look for.")
193
+
194
+
195
+ class ParseExtraContentRule(_SentenceBagRule):
196
+ type: Literal[TestType.EXTRA_CONTENT.value]
197
+
198
+
199
+ class ParseBaselineRule(ParseRuleBase):
200
+ type: Literal[TestType.BASELINE.value]
201
+ max_length: int | None = None
202
+ max_length_skips_image_alt_tags: bool = False
203
+ max_repeats: int = 30
204
+ check_disallowed_characters: bool = True
205
+
206
+
207
+ class ParseOrderRule(ParseRuleBase):
208
+ type: Literal[TestType.ORDER.value]
209
+ before: str = ""
210
+ after: str = ""
211
+ keep_formatting_text_normalisation: bool = False
212
+
213
+ @model_validator(mode="after")
214
+ def validate_layout_bindings(self) -> ParseOrderRule:
215
+ """Validate role-based layout grounding for order rules."""
216
+
217
+ if not self.layout_bindings:
218
+ return self
219
+
220
+ allowed_roles = {"before", "after"}
221
+ invalid_roles = sorted(role for role in self.layout_bindings if role not in allowed_roles)
222
+ if invalid_roles:
223
+ invalid = ", ".join(invalid_roles)
224
+ raise ValueError(f"Order rule layout_bindings only supports roles 'before' and 'after'; got: {invalid}")
225
+
226
+ for role, value in self.layout_bindings.items():
227
+ if isinstance(value, list):
228
+ raise ValueError(f"Order rule layout_bindings.{role} must be a single layout id string")
229
+ if not isinstance(value, str) or not value.strip():
230
+ raise ValueError(f"Order rule layout_bindings.{role} must be a non-empty layout id string")
231
+
232
+ before_layout = self.layout_bindings.get("before")
233
+ if isinstance(before_layout, str):
234
+ # Some legacy datasets contain stale `layout_id` values on order rules.
235
+ # Canonicalize to the role-specific binding instead of failing load.
236
+ if self.layout_id != before_layout:
237
+ self.layout_id = before_layout
238
+
239
+ if before_layout in self.layout_ids:
240
+ ordered_ids = [layout_id for layout_id in self.layout_ids if layout_id != before_layout]
241
+ self.layout_ids = [before_layout, *ordered_ids]
242
+ else:
243
+ self.layout_ids = [before_layout, *self.layout_ids]
244
+ return self
245
+
246
+
247
+ class ParseTableRule(ParseRuleBase):
248
+ type: Literal[TestType.TABLE.value]
249
+ cell: str = ""
250
+ up: str | None = None
251
+ down: str | None = None
252
+ left: str | None = None
253
+ right: str | None = None
254
+ top_heading: str | None = None
255
+ left_heading: str | None = None
256
+ ignore_markdown_tables: bool = False
257
+
258
+
259
+ class ParseTablesValuesRule(ParseRuleBase):
260
+ type: Literal[TestType.TABLES_VALUES.value]
261
+ table_variations: list[Any] | None = None
262
+ json_path: str | None = None
263
+ table_match_threshold: float = 0.8
264
+ table_values_match_threshold: float = 0.9
265
+ add_check_num_rows_test: bool = True
266
+ add_check_num_cols_test: bool = True
267
+
268
+
269
+ class ParseTablesNumRowsRule(ParseRuleBase):
270
+ type: Literal[TestType.TABLES_NUM_ROWS.value]
271
+ expected_num_rows: int = 0
272
+ actual_num_rows: int | None = None
273
+
274
+
275
+ class ParseTablesNumColsRule(ParseRuleBase):
276
+ type: Literal[TestType.TABLES_NUM_COLS.value]
277
+ expected_num_cols: int = 0
278
+ actual_num_cols: int | None = None
279
+
280
+
281
+ class ParseTableMarkerCellsRule(ParseRuleBase):
282
+ """Require icon-like values to survive as cells in one parsed table."""
283
+
284
+ type: Literal[TestType.TABLE_MARKER_CELLS.value]
285
+ min_count: int = Field(default=2, ge=1)
286
+ min_distinct_rows: int = Field(default=2, ge=1)
287
+ min_distinct_columns: int = Field(default=1, ge=1)
288
+ marker_aliases: list[str] = Field(default_factory=list)
289
+ allow_repeated_ocr_glyphs: bool = True
290
+
291
+
292
+ class ParseTableColspanRule(ParseRuleBase, _HasAnchorCells):
293
+ type: Literal[TestType.TABLE_COLSPAN.value]
294
+ cell: str = ""
295
+ expected_colspan: int = 1
296
+
297
+
298
+ class ParseTableRowspanRule(ParseRuleBase, _HasAnchorCells):
299
+ type: Literal[TestType.TABLE_ROWSPAN.value]
300
+ cell: str = ""
301
+ expected_rowspan: int = 1
302
+
303
+
304
+ class ParseTableSameRowRule(ParseRuleBase, _HasAnchorCells):
305
+ type: Literal[TestType.TABLE_SAME_ROW.value]
306
+ cell_a: str = ""
307
+ cell_b: str = ""
308
+
309
+
310
+ class ParseTableSameColumnRule(ParseRuleBase, _HasAnchorCells):
311
+ type: Literal[TestType.TABLE_SAME_COLUMN.value]
312
+ cell_a: str = ""
313
+ cell_b: str = ""
314
+
315
+
316
+ class ParseTableHeaderChainRule(ParseRuleBase, _HasAnchorCells):
317
+ type: Literal[TestType.TABLE_HEADER_CHAIN.value]
318
+ data_cell: str = ""
319
+ column_headers: list[Any] = Field(default_factory=list)
320
+ row_headers: list[Any] = Field(default_factory=list)
321
+
322
+
323
+ class ParseTableAdjacentUpRule(ParseRuleBase, _HasAnchorCells):
324
+ type: Literal[TestType.TABLE_ADJACENT_UP.value]
325
+ anchor_cell: str = ""
326
+ expected_neighbor: str = ""
327
+
328
+
329
+ class ParseTableAdjacentDownRule(ParseRuleBase, _HasAnchorCells):
330
+ type: Literal[TestType.TABLE_ADJACENT_DOWN.value]
331
+ anchor_cell: str = ""
332
+ expected_neighbor: str = ""
333
+
334
+
335
+ class ParseTableAdjacentLeftRule(ParseRuleBase, _HasAnchorCells):
336
+ type: Literal[TestType.TABLE_ADJACENT_LEFT.value]
337
+ anchor_cell: str = ""
338
+ expected_neighbor: str = ""
339
+
340
+
341
+ class ParseTableAdjacentRightRule(ParseRuleBase, _HasAnchorCells):
342
+ type: Literal[TestType.TABLE_ADJACENT_RIGHT.value]
343
+ anchor_cell: str = ""
344
+ expected_neighbor: str = ""
345
+
346
+
347
+ class ParseTableTopHeaderRule(ParseRuleBase, _HasAnchorCells):
348
+ type: Literal[TestType.TABLE_TOP_HEADER.value]
349
+ data_cell: str = ""
350
+ expected_header: str = ""
351
+
352
+
353
+ class ParseTableLeftHeaderRule(ParseRuleBase, _HasAnchorCells):
354
+ type: Literal[TestType.TABLE_LEFT_HEADER.value]
355
+ data_cell: str = ""
356
+ expected_header: str = ""
357
+
358
+
359
+ class ParseTableNoLeftRule(ParseRuleBase, _HasAnchorCells):
360
+ type: Literal[TestType.TABLE_NO_LEFT.value]
361
+ cell: str = ""
362
+
363
+
364
+ class ParseTableNoRightRule(ParseRuleBase, _HasAnchorCells):
365
+ type: Literal[TestType.TABLE_NO_RIGHT.value]
366
+ cell: str = ""
367
+
368
+
369
+ class ParseTableNoAboveRule(ParseRuleBase, _HasAnchorCells):
370
+ type: Literal[TestType.TABLE_NO_ABOVE.value]
371
+ cell: str = ""
372
+
373
+
374
+ class ParseTableNoBelowRule(ParseRuleBase, _HasAnchorCells):
375
+ type: Literal[TestType.TABLE_NO_BELOW.value]
376
+ cell: str = ""
377
+
378
+
379
+ class ParseChartDataPointRule(ParseRuleBase):
380
+ type: Literal[TestType.CHART_DATA_POINT.value]
381
+ value: str | int | float | None = None
382
+ labels: list[str] = Field(default_factory=list)
383
+ normalize_numbers: bool = True
384
+ relative_tolerance: float = 0.01
385
+
386
+
387
+ class ParseChartDataArrayLabelsRule(ParseRuleBase):
388
+ type: Literal[TestType.CHART_DATA_ARRAY_LABELS.value]
389
+ data: list[Any] = Field(default_factory=list)
390
+ x_axis_shuffle: bool = False
391
+ transposed: bool = False
392
+ csv_path: str | None = Field(default=None, alias="_csv_path")
393
+
394
+
395
+ class ParseChartDataArrayDataRule(ParseRuleBase):
396
+ type: Literal[TestType.CHART_DATA_ARRAY_DATA.value]
397
+ data: list[Any] = Field(default_factory=list)
398
+ x_axis_shuffle: bool = False
399
+ y_axis_shuffle: bool = False
400
+ normalize_numbers: bool = True
401
+ transposed: bool = False
402
+ csv_path: str | None = Field(default=None, alias="_csv_path")
403
+
404
+
405
+ class ParseFormFieldRule(ParseRuleBase):
406
+ """Schema for `form_field` rules.
407
+
408
+ Locates a labeled field in the parsed markdown/HTML and checks its value.
409
+ Used for benchmarking form (key-value, checkbox, signature) extraction.
410
+
411
+ `label` may be a list of strings for table-like form cells where one
412
+ value is identified by multiple visible keys (for example a row label
413
+ plus a column label). Label-list order is not significant.
414
+
415
+ For repeated yes/no checkbox groups, use a label list containing the
416
+ question and option text, e.g. `["Multistage cement?", "No"]`, with
417
+ `value_type: "checkbox"` and `value: true` or `false`.
418
+
419
+ For repeated row fields such as open-hole intervals, use the existing
420
+ row-index form, e.g. `"FROM (row 1)"` and `"TO (row 1)"`. This works for
421
+ markdown tables and flattened parser output such as `FROM none TO RRC`.
422
+
423
+ `label_max_diffs` may be a number or a list aligned with `label` to allow
424
+ limited OCR/handwriting variation in key text after normalization.
425
+ `value_max_diffs` is the equivalent tolerance for the extracted value.
426
+
427
+ `value` may be a list of strings to declare acceptable alternatives for
428
+ genuinely ambiguous fields (e.g. illegible handwriting). The evaluator
429
+ passes if the parsed value matches *any* alternative. Use sparingly —
430
+ most rules should be a single string. List form is only for value_type
431
+ "text" and "signature".
432
+ """
433
+
434
+ type: Literal[TestType.FORM_FIELD.value]
435
+ label: str | list[str] = ""
436
+ label_max_diffs: int | float | list[int | float] = 0
437
+ value_max_diffs: int | float = 0
438
+ value: str | bool | list[str] = ""
439
+ value_type: Literal["text", "checkbox", "signature"] = "text"
440
+
441
+
442
+ class ParseFormattingRule(ParseRuleBase):
443
+ type: Literal[
444
+ TestType.IS_UNDERLINE.value,
445
+ TestType.IS_NOT_UNDERLINE.value,
446
+ TestType.IS_BOLD.value,
447
+ TestType.IS_NOT_BOLD.value,
448
+ TestType.IS_STRIKEOUT.value,
449
+ TestType.IS_NOT_STRIKEOUT.value,
450
+ TestType.IS_ITALIC.value,
451
+ TestType.IS_NOT_ITALIC.value,
452
+ TestType.IS_MARK.value,
453
+ TestType.IS_NOT_MARK.value,
454
+ TestType.IS_SUP.value,
455
+ TestType.IS_NOT_SUP.value,
456
+ TestType.IS_SUB.value,
457
+ TestType.IS_NOT_SUB.value,
458
+ ]
459
+ text: str = ""
460
+
461
+
462
+ class ParseMarkColorRule(ParseRuleBase):
463
+ """Schema for `mark_color` rules.
464
+
465
+ Validates that text is wrapped in a ``<mark>`` tag AND that the tag
466
+ contains the expected color string in one of its attributes
467
+ (e.g. ``style="background-color: yellow"``, ``background="yellow"``,
468
+ ``backgroundColor="yellow"``).
469
+ """
470
+
471
+ type: Literal[TestType.MARK_COLOR.value]
472
+ text: str = ""
473
+ color: str = ""
474
+
475
+
476
+ class ParseAbsentUnlessStrikeoutRule(ParseRuleBase):
477
+ """Schema for `absent_unless_strikeout` rules.
478
+
479
+ For text deleted in a revision (struck through in the source document):
480
+ the parse should either omit the text entirely, or keep it explicitly
481
+ marked as struck (``~~text~~``, ``<s>``, ``<del>``, ``<strike>`` or a
482
+ ``text-decoration: line-through`` style). The rule FAILS when the text
483
+ appears as regular, unmarked content — the failure mode where deleted
484
+ contract language becomes indistinguishable from current language.
485
+ """
486
+
487
+ type: Literal[TestType.ABSENT_UNLESS_STRIKEOUT.value]
488
+ text: str = ""
489
+
490
+
491
+ class ParsePresentAsStrikeoutRule(ParseRuleBase):
492
+ """Schema for `present_as_strikeout` rules.
493
+
494
+ Stricter counterpart of `absent_unless_strikeout`: deleted (struck)
495
+ source text must be RETAINED in the output AND sit inside explicit
496
+ strikeout markup. Fails both when the text is dropped entirely and when
497
+ it appears only as plain content. Region-based like
498
+ `absent_unless_strikeout`, so wider-than-GT struck spans pass.
499
+ """
500
+
501
+ type: Literal[TestType.PRESENT_AS_STRIKEOUT.value]
502
+ text: str = ""
503
+
504
+
505
+ class ParseTextColorRule(ParseRuleBase):
506
+ """Schema for `text_color` rules.
507
+
508
+ Validates that text appears inside an HTML tag whose attributes carry a
509
+ text color matching the expected color family (e.g. ``red``, ``blue``,
510
+ ``green``, ``orange``). Attribute colors may be named colors, hex codes
511
+ (``#c69200``) or ``rgb()`` values; matching is done by hue family, not
512
+ exact value, so ``#ffa6a6`` satisfies ``red``.
513
+
514
+ Primary use: colored revision text in legal redlines, where color encodes
515
+ revision semantics (e.g. red struck = deleted, blue underlined = inserted).
516
+ """
517
+
518
+ type: Literal[TestType.TEXT_COLOR.value]
519
+ text: str = ""
520
+ color: str = ""
521
+
522
+
523
+ class ParseTitleRule(ParseRuleBase):
524
+ type: Literal[TestType.IS_TITLE.value]
525
+ text: str = ""
526
+ level: int | None = None
527
+
528
+
529
+ class ParseListLevelRule(ParseRuleBase):
530
+ """Schema for `list_level` rules.
531
+
532
+ Validates that `text` appears as a list item at nesting level `level`
533
+ (1 = top level). Levels are computed from the output with CommonMark
534
+ list-item semantics (https://spec.commonmark.org/0.31.2/#list-items): a
535
+ nested item's marker must sit at or beyond the column of the first content
536
+ character of the item it nests under - there is no fixed space count.
537
+ HTML `<ul>/<ol>/<li>` nesting is accepted as equivalent. Deliberately not
538
+ evaluated for indentation inside table cells.
539
+ """
540
+
541
+ type: Literal[TestType.LIST_LEVEL.value]
542
+ text: str = ""
543
+ level: int | None = None
544
+
545
+
546
+ class ParseLatexRule(ParseRuleBase):
547
+ type: Literal[TestType.IS_LATEX.value]
548
+ formula: str
549
+
550
+
551
+ class ParseNotLatexRule(ParseRuleBase):
552
+ """Formula-like text that must remain ordinary Markdown."""
553
+
554
+ type: Literal[TestType.IS_NOT_LATEX.value]
555
+ text: str
556
+
557
+
558
+ class ParseCodeBlockRule(ParseRuleBase):
559
+ type: Literal[TestType.IS_CODE_BLOCK.value]
560
+ language: str = ""
561
+ code: str = ""
562
+
563
+
564
+ class ParseTitleHierarchyPercentRule(ParseRuleBase):
565
+ type: Literal[TestType.TITLE_HIERARCHY_PERCENT.value]
566
+ title_hierarchy: dict[str, Any] = Field(default_factory=dict)
567
+
568
+
569
+ class HeadingExpectation(DictCompatPydanticModel):
570
+ """One visible page heading in reading order."""
571
+
572
+ text: str = Field(min_length=1)
573
+ level: int = Field(ge=1, le=6)
574
+
575
+
576
+ class ParseHeadingStructureRule(ParseRuleBase):
577
+ """Complete ordered heading inventory for one transcribed page."""
578
+
579
+ type: Literal[TestType.HEADING_STRUCTURE.value]
580
+ # Empty is a meaningful negative annotation, so callers must state it
581
+ # explicitly instead of silently getting a no-headings rule after a typo.
582
+ headings: list[HeadingExpectation]
583
+
584
+
585
+ class ParsePageSectionRule(ParseRuleBase):
586
+ type: Literal[TestType.IS_HEADER.value, TestType.IS_FOOTER.value]
587
+ text: str = ""
588
+
589
+
590
+ class ParseBagOfDigitPercentRule(ParseRuleBase):
591
+ """Schema for `bag_of_digit_percent` rule.
592
+
593
+ Compares digit frequency (0-9) between expected markdown and actual output.
594
+ Digits inside HTML tag attributes (e.g. colspan="2") are excluded.
595
+ """
596
+
597
+ type: Literal[TestType.BAG_OF_DIGIT_PERCENT.value]
598
+ bag_of_digit: dict[str, int] = Field(default_factory=dict)
599
+
600
+
601
+ class ParseRotateCheckRule(ParseRuleBase):
602
+ type: Literal[TestType.ROTATE_CHECK.value]
603
+ value: int | float | str | None = None
604
+
605
+
606
+ class ParseWatermarkRemovalRule(ParseRuleBase):
607
+ """Score watermark suppression without rewarding destroyed body text."""
608
+
609
+ type: Literal[TestType.WATERMARK_REMOVAL.value]
610
+ page: int = Field(ge=1)
611
+ watermark_texts: list[str] = Field(min_length=1)
612
+ allowed_occurrences: list[int] | None = Field(
613
+ default=None,
614
+ description=(
615
+ "Maximum output occurrences allowed for each watermark phrase. Use this when the same phrase "
616
+ "also occurs legitimately in the page body; defaults to zero for every phrase."
617
+ ),
618
+ )
619
+ preserve_texts: list[str] = Field(min_length=1)
620
+ watermark_match_threshold: float = Field(default=0.80, ge=0.0, le=1.0)
621
+ preserve_match_threshold: float = Field(default=0.85, ge=0.0, le=1.0)
622
+ removal_pass_threshold: float = Field(default=1.0, ge=0.0, le=1.0)
623
+ preservation_pass_threshold: float = Field(default=0.80, ge=0.0, le=1.0)
624
+
625
+ @model_validator(mode="after")
626
+ def validate_allowed_occurrences(self) -> ParseWatermarkRemovalRule:
627
+ if self.allowed_occurrences is None:
628
+ return self
629
+ if len(self.allowed_occurrences) != len(self.watermark_texts):
630
+ raise ValueError("allowed_occurrences must have one value per watermark_texts entry")
631
+ if any(value < 0 for value in self.allowed_occurrences):
632
+ raise ValueError("allowed_occurrences values must be non-negative")
633
+ return self
634
+
635
+
636
+ class ParseDiagramGraphRule(ParseRuleBase):
637
+ """One diagram the parser must render as a mermaid block whose *graph* matches ``graph``.
638
+
639
+ Mermaid source is never compared: both sides are parsed into labelled nodes and edges,
640
+ nodes are aligned by fuzzy label similarity (Hungarian assignment) and edges are scored on
641
+ the aligned node ids. ``graph`` is ``{"nodes": [{"id","label"}], "edges": [{"from","to",
642
+ "label"?, "directed"?}], "groups"?: [{"id","label","members"}]}``. ``bbox`` is normalized
643
+ COCO ``[x, y, w, h]`` in page fractions, as for inline images.
644
+ """
645
+
646
+ type: Literal[TestType.DIAGRAM_GRAPH.value]
647
+ graph: dict[str, Any] = Field(default_factory=dict, description="Ground-truth graph: nodes, edges, optional groups")
648
+ diagram_class: str = Field(default="flowchart", description="Annotated kind (flowchart, org_chart, process, ...)")
649
+ accepted_types: list[str] = Field(
650
+ default_factory=lambda: ["flowchart"],
651
+ description="Mermaid diagram types that may represent this figure (flowchart, state, sequence, mindmap, ...)",
652
+ )
653
+ bbox: list[float] = Field(default_factory=list, description="Normalized [x, y, w, h] of the diagram on the page")
654
+ caption: str | None = Field(default=None, description="Verbatim caption printed next to the diagram, if any")
655
+ reference_image: str | None = Field(default=None, description="Crop PNG next to the test case, for the report")
656
+ text_before: list[str] = Field(default_factory=list, description="Text lines just above the diagram, reading order")
657
+ text_after: list[str] = Field(default_factory=list, description="Text lines just below the diagram, reading order")
658
+ annotator_confidence: float | None = Field(default=None, description="Annotator confidence in the graph")
659
+ node_match_threshold: int = Field(
660
+ default=80, description="rapidfuzz token_set_ratio at or above which two labels align"
661
+ )
662
+ node_recall_threshold: float = Field(default=0.80, description="Minimum share of expected nodes found")
663
+ node_precision_threshold: float = Field(
664
+ default=0.70, description="Minimum share of predicted nodes that are expected"
665
+ )
666
+ edge_f1_threshold: float = Field(default=0.60, description="Minimum edge F1 over aligned nodes")
667
+ edge_label_threshold: int = Field(default=70, description="Fuzzy ratio for edge labels when the reference has one")
668
+
669
+ @field_validator("bbox")
670
+ @classmethod
671
+ def _validate_bbox(cls, v: list[float]) -> list[float]:
672
+ if v and (len(v) != 4 or any(not isinstance(x, (int, float)) for x in v)):
673
+ raise ValueError("bbox must be [x, y, w, h]")
674
+ return [float(x) for x in v]
675
+
676
+ @field_validator("graph")
677
+ @classmethod
678
+ def _validate_graph(cls, v: dict[str, Any]) -> dict[str, Any]:
679
+ nodes = v.get("nodes") or []
680
+ ids = {str(n.get("id")) for n in nodes if isinstance(n, dict)}
681
+ if not ids:
682
+ raise ValueError("graph must declare at least one node")
683
+ for e in v.get("edges") or []:
684
+ if str(e.get("from")) not in ids or str(e.get("to")) not in ids:
685
+ raise ValueError(f"edge {e} references an unknown node id")
686
+ return v
687
+
688
+
689
+ class ParseDiagramEdgeRule(ParseRuleBase):
690
+ """One relation that must (or must not) hold between two labelled nodes of a mermaid diagram.
691
+
692
+ Anchors are the robust complement to the graph rule: they are cheap to annotate, easy to
693
+ verify by eye, and insensitive to how creatively the rest of the diagram was transcribed.
694
+ ``relation`` is ``edge`` (a direct link source→target), ``path`` (source reaches target
695
+ through any directed chain, tolerating inserted intermediate nodes) or ``no_edge``.
696
+ """
697
+
698
+ type: Literal[TestType.DIAGRAM_EDGE.value]
699
+ source: str = Field(description="Label of the source node")
700
+ target: str = Field(description="Label of the target node")
701
+ relation: Literal["edge", "path", "no_edge"] = Field(default="edge")
702
+ label: str | None = Field(default=None, description="Expected edge text (e.g. Yes / No), fuzzy-matched when set")
703
+ directed: bool = Field(default=True, description="False accepts the link in either direction")
704
+ diagram_id: str | None = Field(default=None, description="``id`` of the diagram_graph rule this anchor belongs to")
705
+ node_match_threshold: int = Field(default=80, description="rapidfuzz token_set_ratio to identify source/target")
706
+ edge_label_threshold: int = Field(default=70, description="Fuzzy ratio for the edge label when set")
707
+
708
+
709
+ class ParseDiagramCountRule(ParseRuleBase):
710
+ """Page-level decision: the right number of *parseable* mermaid blocks, none where none belong.
711
+
712
+ ``expected_count`` is 0 on negative pages (charts, schematics, maps, infographics) where any
713
+ mermaid block fails the rule. Blocks that do not parse into at least one node count against
714
+ the page everywhere.
715
+ """
716
+
717
+ type: Literal[TestType.DIAGRAM_COUNT.value]
718
+ expected_count: int = Field(default=0, description="Number of mermaid blocks the page should carry")
719
+ strict: bool = Field(default=True, description="Penalise extra parseable blocks beyond expected_count")
720
+
721
+
722
+ class ParsePageDecorationRule(ParseRuleBase):
723
+ """Running header, running footer and printed page number of one page.
724
+
725
+ ``None`` for a slot means the page has none and the parser must leave it empty; a string is
726
+ the verbatim furniture text (header/footer pieces joined by `` | `` in reading order). The
727
+ rule reads the parser's structured page fields when available, else the markdown tags, and
728
+ also checks that the furniture text does not remain in the body markdown.
729
+ """
730
+
731
+ type: Literal[TestType.PAGE_DECORATION.value]
732
+ header: str | None = Field(
733
+ default=None, description="Running header text, pieces joined by ' | '; None = no header"
734
+ )
735
+ footer: str | None = Field(
736
+ default=None, description="Running footer text, pieces joined by ' | '; None = no footer"
737
+ )
738
+ page_number: str | None = Field(default=None, description="Canonical printed page number (3, iv, A-3); None = none")
739
+ page_number_raw: str | None = Field(default=None, description="Page number as printed (Page 3 of 10), for leakage")
740
+ text_threshold: int = Field(default=85, description="token_set_ratio at or above which header/footer text matches")
741
+ leak_min_chars: int = Field(default=4, description="Furniture pieces shorter than this are not checked for leakage")
742
+ annotator_confidence: float | None = Field(default=None, description="Annotator confidence, informational")
743
+
744
+
745
+ type ParseRule = (
746
+ ParsePresenceRule
747
+ | ParseUnexpectedSentenceRule
748
+ | ParseUnexpectedSentencePercentRule
749
+ | ParseTooManySentenceOccurrenceRule
750
+ | ParseTooManySentenceOccurrencePercentRule
751
+ | ParseMissingSentenceRule
752
+ | ParseMissingSentencePercentRule
753
+ | ParseMissingSpecificSentenceRule
754
+ | ParseUnexpectedWordRule
755
+ | ParseUnexpectedWordPercentRule
756
+ | ParseTooManyWordOccurrenceRule
757
+ | ParseTooManyWordOccurrencePercentRule
758
+ | ParseMissingWordRule
759
+ | ParseMissingWordPercentRule
760
+ | ParseMissingSpecificWordRule
761
+ | ParseExtraContentRule
762
+ | ParseBaselineRule
763
+ | ParseOrderRule
764
+ | ParseTableRule
765
+ | ParseTablesValuesRule
766
+ | ParseTablesNumRowsRule
767
+ | ParseTablesNumColsRule
768
+ | ParseTableMarkerCellsRule
769
+ | ParseTableColspanRule
770
+ | ParseTableRowspanRule
771
+ | ParseTableSameRowRule
772
+ | ParseTableSameColumnRule
773
+ | ParseTableHeaderChainRule
774
+ | ParseTableAdjacentUpRule
775
+ | ParseTableAdjacentDownRule
776
+ | ParseTableAdjacentLeftRule
777
+ | ParseTableAdjacentRightRule
778
+ | ParseTableTopHeaderRule
779
+ | ParseTableLeftHeaderRule
780
+ | ParseTableNoLeftRule
781
+ | ParseTableNoRightRule
782
+ | ParseTableNoAboveRule
783
+ | ParseTableNoBelowRule
784
+ | ParseChartDataPointRule
785
+ | ParseChartDataArrayLabelsRule
786
+ | ParseChartDataArrayDataRule
787
+ | ParseFormFieldRule
788
+ | ParseFormattingRule
789
+ | ParseMarkColorRule
790
+ | ParseTextColorRule
791
+ | ParseAbsentUnlessStrikeoutRule
792
+ | ParsePresentAsStrikeoutRule
793
+ | ParseLatexRule
794
+ | ParseNotLatexRule
795
+ | ParseCodeBlockRule
796
+ | ParseTitleRule
797
+ | ParseTitleHierarchyPercentRule
798
+ | ParseHeadingStructureRule
799
+ | ParseListLevelRule
800
+ | ParsePageSectionRule
801
+ | ParseBagOfDigitPercentRule
802
+ | ParseRotateCheckRule
803
+ | ParseWatermarkRemovalRule
804
+ | ParseDiagramGraphRule
805
+ | ParseDiagramEdgeRule
806
+ | ParseDiagramCountRule
807
+ | ParsePageDecorationRule
808
+ )
809
+
810
+ type ParseRuleInput = ParseRule | dict[str, Any]
811
+
812
+ _RULE_TYPE_TO_MODEL: dict[str, type[ParseRuleBase]] = {
813
+ TestType.PRESENT.value: ParsePresenceRule,
814
+ TestType.ABSENT.value: ParsePresenceRule,
815
+ TestType.UNEXPECTED_SENTENCE.value: ParseUnexpectedSentenceRule,
816
+ TestType.UNEXPECTED_SENTENCE_PERCENT.value: ParseUnexpectedSentencePercentRule,
817
+ TestType.TOO_MANY_SENTENCE_OCCURENCE.value: ParseTooManySentenceOccurrenceRule,
818
+ TestType.TOO_MANY_SENTENCE_OCCURENCE_PERCENT.value: ParseTooManySentenceOccurrencePercentRule,
819
+ TestType.MISSING_SENTENCE.value: ParseMissingSentenceRule,
820
+ TestType.MISSING_SENTENCE_PERCENT.value: ParseMissingSentencePercentRule,
821
+ TestType.MISSING_SPECIFIC_SENTENCE.value: ParseMissingSpecificSentenceRule,
822
+ TestType.UNEXPECTED_WORD.value: ParseUnexpectedWordRule,
823
+ TestType.UNEXPECTED_WORD_PERCENT.value: ParseUnexpectedWordPercentRule,
824
+ TestType.TOO_MANY_WORD_OCCURENCE.value: ParseTooManyWordOccurrenceRule,
825
+ TestType.TOO_MANY_WORD_OCCURENCE_PERCENT.value: ParseTooManyWordOccurrencePercentRule,
826
+ TestType.MISSING_WORD.value: ParseMissingWordRule,
827
+ TestType.MISSING_WORD_PERCENT.value: ParseMissingWordPercentRule,
828
+ TestType.MISSING_SPECIFIC_WORD.value: ParseMissingSpecificWordRule,
829
+ TestType.EXTRA_CONTENT.value: ParseExtraContentRule,
830
+ TestType.BASELINE.value: ParseBaselineRule,
831
+ TestType.ORDER.value: ParseOrderRule,
832
+ TestType.TABLE.value: ParseTableRule,
833
+ TestType.TABLES_VALUES.value: ParseTablesValuesRule,
834
+ TestType.TABLES_NUM_ROWS.value: ParseTablesNumRowsRule,
835
+ TestType.TABLES_NUM_COLS.value: ParseTablesNumColsRule,
836
+ TestType.TABLE_MARKER_CELLS.value: ParseTableMarkerCellsRule,
837
+ TestType.TABLE_COLSPAN.value: ParseTableColspanRule,
838
+ TestType.TABLE_ROWSPAN.value: ParseTableRowspanRule,
839
+ TestType.TABLE_SAME_ROW.value: ParseTableSameRowRule,
840
+ TestType.TABLE_SAME_COLUMN.value: ParseTableSameColumnRule,
841
+ TestType.TABLE_HEADER_CHAIN.value: ParseTableHeaderChainRule,
842
+ TestType.TABLE_ADJACENT_UP.value: ParseTableAdjacentUpRule,
843
+ TestType.TABLE_ADJACENT_DOWN.value: ParseTableAdjacentDownRule,
844
+ TestType.TABLE_ADJACENT_LEFT.value: ParseTableAdjacentLeftRule,
845
+ TestType.TABLE_ADJACENT_RIGHT.value: ParseTableAdjacentRightRule,
846
+ TestType.TABLE_TOP_HEADER.value: ParseTableTopHeaderRule,
847
+ TestType.TABLE_LEFT_HEADER.value: ParseTableLeftHeaderRule,
848
+ TestType.TABLE_NO_LEFT.value: ParseTableNoLeftRule,
849
+ TestType.TABLE_NO_RIGHT.value: ParseTableNoRightRule,
850
+ TestType.TABLE_NO_ABOVE.value: ParseTableNoAboveRule,
851
+ TestType.TABLE_NO_BELOW.value: ParseTableNoBelowRule,
852
+ TestType.CHART_DATA_POINT.value: ParseChartDataPointRule,
853
+ TestType.CHART_DATA_ARRAY_LABELS.value: ParseChartDataArrayLabelsRule,
854
+ TestType.CHART_DATA_ARRAY_DATA.value: ParseChartDataArrayDataRule,
855
+ TestType.FORM_FIELD.value: ParseFormFieldRule,
856
+ TestType.IS_UNDERLINE.value: ParseFormattingRule,
857
+ TestType.IS_NOT_UNDERLINE.value: ParseFormattingRule,
858
+ TestType.IS_BOLD.value: ParseFormattingRule,
859
+ TestType.IS_NOT_BOLD.value: ParseFormattingRule,
860
+ TestType.IS_STRIKEOUT.value: ParseFormattingRule,
861
+ TestType.IS_NOT_STRIKEOUT.value: ParseFormattingRule,
862
+ TestType.IS_ITALIC.value: ParseFormattingRule,
863
+ TestType.IS_NOT_ITALIC.value: ParseFormattingRule,
864
+ TestType.IS_MARK.value: ParseFormattingRule,
865
+ TestType.IS_NOT_MARK.value: ParseFormattingRule,
866
+ TestType.MARK_COLOR.value: ParseMarkColorRule,
867
+ TestType.TEXT_COLOR.value: ParseTextColorRule,
868
+ TestType.ABSENT_UNLESS_STRIKEOUT.value: ParseAbsentUnlessStrikeoutRule,
869
+ TestType.PRESENT_AS_STRIKEOUT.value: ParsePresentAsStrikeoutRule,
870
+ TestType.IS_SUP.value: ParseFormattingRule,
871
+ TestType.IS_NOT_SUP.value: ParseFormattingRule,
872
+ TestType.IS_SUB.value: ParseFormattingRule,
873
+ TestType.IS_NOT_SUB.value: ParseFormattingRule,
874
+ TestType.IS_LATEX.value: ParseLatexRule,
875
+ TestType.IS_NOT_LATEX.value: ParseNotLatexRule,
876
+ TestType.IS_CODE_BLOCK.value: ParseCodeBlockRule,
877
+ TestType.IS_TITLE.value: ParseTitleRule,
878
+ TestType.TITLE_HIERARCHY_PERCENT.value: ParseTitleHierarchyPercentRule,
879
+ TestType.HEADING_STRUCTURE.value: ParseHeadingStructureRule,
880
+ TestType.LIST_LEVEL.value: ParseListLevelRule,
881
+ TestType.IS_HEADER.value: ParsePageSectionRule,
882
+ TestType.IS_FOOTER.value: ParsePageSectionRule,
883
+ TestType.BAG_OF_DIGIT_PERCENT.value: ParseBagOfDigitPercentRule,
884
+ TestType.ROTATE_CHECK.value: ParseRotateCheckRule,
885
+ TestType.WATERMARK_REMOVAL.value: ParseWatermarkRemovalRule,
886
+ TestType.DIAGRAM_GRAPH.value: ParseDiagramGraphRule,
887
+ TestType.DIAGRAM_EDGE.value: ParseDiagramEdgeRule,
888
+ TestType.DIAGRAM_COUNT.value: ParseDiagramCountRule,
889
+ TestType.PAGE_DECORATION.value: ParsePageDecorationRule,
890
+ }
891
+
892
+ _BUILTIN_RULE_TYPES: frozenset[str] = frozenset(_RULE_TYPE_TO_MODEL)
893
+
894
+
895
+ def get_rule_type(rule: ParseRuleInput | Any) -> str | None:
896
+ """Return the `type` field from either a typed schema or raw dict."""
897
+
898
+ if isinstance(rule, ParseRuleBase):
899
+ return rule.type
900
+ if isinstance(rule, BaseModel):
901
+ value = getattr(rule, "type", None)
902
+ return value if isinstance(value, str) else None
903
+ if isinstance(rule, dict):
904
+ raw_type = rule.get("type")
905
+ return raw_type if isinstance(raw_type, str) else None
906
+ return None
907
+
908
+
909
+ def get_rule_id(rule: ParseRuleInput | Any) -> str | None:
910
+ """Return optional `id` from either typed schema or raw dict."""
911
+
912
+ if isinstance(rule, ParseRuleBase):
913
+ return rule.id
914
+ if isinstance(rule, BaseModel):
915
+ value = getattr(rule, "id", None)
916
+ return value if isinstance(value, str) else None
917
+ if isinstance(rule, dict):
918
+ value = rule.get("id")
919
+ return value if isinstance(value, str) else None
920
+ return None
921
+
922
+
923
+ def get_rule_page(rule: ParseRuleInput | Any) -> int | None:
924
+ """Return optional `page` from either typed schema or raw dict."""
925
+
926
+ if isinstance(rule, ParseRuleBase):
927
+ return rule.page
928
+ if isinstance(rule, BaseModel):
929
+ value = getattr(rule, "page", None)
930
+ return value if isinstance(value, int) else None
931
+ if isinstance(rule, dict):
932
+ value = rule.get("page")
933
+ return value if isinstance(value, int) else None
934
+ return None
935
+
936
+
937
+ def get_rule_layout_id(rule: ParseRuleInput | Any) -> str | None:
938
+ """Return primary `layout_id` from either typed schema or raw dict."""
939
+
940
+ if isinstance(rule, ParseRuleBase):
941
+ return rule.layout_id
942
+ if isinstance(rule, BaseModel):
943
+ value = getattr(rule, "layout_id", None)
944
+ if isinstance(value, str):
945
+ return value
946
+ layout_ids = get_rule_layout_ids(rule)
947
+ return layout_ids[0] if layout_ids else None
948
+ if isinstance(rule, dict):
949
+ value = rule.get("layout_id")
950
+ if isinstance(value, str):
951
+ return value
952
+ layout_ids = get_rule_layout_ids(rule)
953
+ return layout_ids[0] if layout_ids else None
954
+ return None
955
+
956
+
957
+ def get_rule_layout_ids(rule: ParseRuleInput | Any) -> list[str]:
958
+ """Return normalized `layout_ids` from either typed schema or raw dict."""
959
+
960
+ if isinstance(rule, ParseRuleBase):
961
+ return list(rule.layout_ids)
962
+ if isinstance(rule, BaseModel):
963
+ value = getattr(rule, "layout_ids", None)
964
+ if isinstance(value, list):
965
+ return [v for v in value if isinstance(v, str)]
966
+ return []
967
+ if isinstance(rule, dict):
968
+ value = rule.get("layout_ids")
969
+ if isinstance(value, list):
970
+ return [v for v in value if isinstance(v, str)]
971
+ fallback = rule.get("layout_id")
972
+ if isinstance(fallback, str):
973
+ return [fallback]
974
+ return []
975
+
976
+
977
+ def get_rule_layout_bindings(rule: ParseRuleInput | Any) -> dict[str, str | list[str]]:
978
+ """Return `layout_bindings` from either typed schema or raw dict."""
979
+
980
+ if isinstance(rule, ParseRuleBase):
981
+ return dict(rule.layout_bindings)
982
+
983
+ def _is_valid_binding_value(value: Any) -> bool:
984
+ return isinstance(value, str) or (isinstance(value, list) and all(isinstance(item, str) for item in value))
985
+
986
+ if isinstance(rule, BaseModel):
987
+ value = getattr(rule, "layout_bindings", None)
988
+ if isinstance(value, dict):
989
+ return {str(k): v for k, v in value.items() if _is_valid_binding_value(v)}
990
+ return {}
991
+ if isinstance(rule, dict):
992
+ value = rule.get("layout_bindings")
993
+ if isinstance(value, dict):
994
+ return {str(k): v for k, v in value.items() if _is_valid_binding_value(v)}
995
+ return {}
996
+
997
+
998
+ def rule_to_dict(rule: ParseRuleInput | Any) -> dict[str, Any]:
999
+ """Serialize a typed or raw rule to a mutable dict for reporting/evals."""
1000
+
1001
+ if isinstance(rule, BaseModel):
1002
+ return rule.model_dump(by_alias=True)
1003
+ if isinstance(rule, dict):
1004
+ return dict(rule)
1005
+ raise TypeError(f"Expected ParseRule or dict, got {type(rule)!r}")
1006
+
1007
+
1008
+ def register_parse_rule_model(rule_type: str, model: type[ParseRuleBase]) -> None:
1009
+ """Register the pydantic schema for an additional rule ``type``.
1010
+
1011
+ Extensions pair this with ``rules_base.register_rule_class`` (or call
1012
+ ``parse_bench.extensions.register_rule_type`` which does both). Built-in
1013
+ rule types cannot be replaced.
1014
+ """
1015
+ if not isinstance(rule_type, str) or not rule_type:
1016
+ raise ValueError("rule_type must be a non-empty string")
1017
+ if not (isinstance(model, type) and issubclass(model, ParseRuleBase)):
1018
+ raise TypeError("model must subclass ParseRuleBase")
1019
+ existing = _RULE_TYPE_TO_MODEL.get(rule_type)
1020
+ if existing is not None and existing is not model and rule_type in _BUILTIN_RULE_TYPES:
1021
+ raise ValueError(f"{rule_type!r} is a built-in rule type and cannot be replaced")
1022
+ _RULE_TYPE_TO_MODEL[rule_type] = model
1023
+
1024
+
1025
+ def registered_parse_rule_types() -> list[str]:
1026
+ """Every rule ``type`` with a registered schema (built-ins and extensions)."""
1027
+ return list(_RULE_TYPE_TO_MODEL)
1028
+
1029
+
1030
+ def coerce_parse_rule(rule_data: ParseRuleInput | Any) -> ParseRuleBase:
1031
+ """Coerce raw rule payloads into the typed parse rule model.
1032
+
1033
+ Idempotent: already-typed rules are returned as-is.
1034
+ """
1035
+
1036
+ if isinstance(rule_data, ParseRuleBase):
1037
+ return rule_data # type: ignore[return-value]
1038
+
1039
+ if isinstance(rule_data, dict):
1040
+ rule_dict = rule_data
1041
+ else:
1042
+ raise TypeError(f"Expected rule payload as dict or ParseRule, got {type(rule_data)!r}")
1043
+
1044
+ rule_type = rule_dict.get("type")
1045
+ if not isinstance(rule_type, str):
1046
+ raise ValueError("Rule payload must contain a string 'type' field")
1047
+
1048
+ model_cls = _RULE_TYPE_TO_MODEL.get(rule_type)
1049
+ if model_cls is None:
1050
+ raise ValueError(f"Unknown parse rule type: {rule_type}")
1051
+
1052
+ return model_cls.model_validate(rule_dict)
1053
+
1054
+
1055
+ def coerce_parse_rule_list(rules: list[dict[str, Any]] | list[ParseRule]) -> list[ParseRuleBase]:
1056
+ """Validate a list of parse rule payloads and return typed objects."""
1057
+
1058
+ parsed: list[ParseRule] = []
1059
+ for rule in rules:
1060
+ parsed.append(coerce_parse_rule(rule))
1061
+ return parsed
1062
+
1063
+
1064
+ def coerce_parse_rule_list_or_none(
1065
+ rules: list[dict[str, Any]] | list[ParseRule] | None,
1066
+ ) -> list[ParseRuleBase] | None:
1067
+ """Coerce rule lists while preserving explicit None for optional fields."""
1068
+
1069
+ if rules is None:
1070
+ return None
1071
+ return coerce_parse_rule_list(rules)