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,340 @@
1
+ """Normalize list-rooted per_table_row extract predictions for evaluation.
2
+
3
+ This module is a pure **shape adapter**. It does not emit any metrics of
4
+ its own. The existing ``extract_value_precision``,
5
+ ``extract_value_recall``, ``extract_value_f1``, ``accuracy``, and
6
+ ``extract_value_pass_rate`` metrics are what score correctly once the
7
+ prediction is normalized; downstream metadata on those metrics carries
8
+ normalization state for debugging and dashboard drill-down.
9
+
10
+ The v0.5 test cases were authored for ``extraction_target=per_doc``, so every
11
+ ``ExtractFieldTestRule.field_path`` is dict-rooted (e.g. ``personnel[0].name``,
12
+ ``client_id``). ``extraction_target=per_table_row`` may emit a bare row list::
13
+
14
+ extracted_data = [{"name": "Alice", ...}, {"name": "Bob", ...}]
15
+
16
+ or, when the API is given the original per-doc schema, a list of document-shaped
17
+ wrappers where each wrapper contains the inferred array field::
18
+
19
+ extracted_data = [
20
+ {"client_id": "C-1", "personnel": [{"name": "Alice"}]},
21
+ {"client_id": "C-1", "personnel": [{"name": "Bob"}]},
22
+ ]
23
+
24
+ The full DataSnipper v0.5 run also exposed singleton scalar-document lists and
25
+ multi-array wrapper lists. The extract evaluator's path walkers are all
26
+ dict-rooted, so this module projects those list-rooted shapes back into the
27
+ per-doc schema shape before scoring.
28
+
29
+ Scope
30
+ -----
31
+ - Dict-rooted predictions are a no-op.
32
+ - Singleton scalar-document lists become the singleton dict.
33
+ - Wrapper rows merge all top-level list fields and preserve representative
34
+ scalar fields.
35
+ - Bare row lists still require one canonical array prefix; scalar rules are
36
+ skipped only in this mode because bare rows cannot structurally emit them.
37
+ - Case-only rule aliases are skipped when the JSON schema has a unique
38
+ canonical top-level key.
39
+
40
+ Accuracy caveat
41
+ ---------------
42
+ The whole-JSON ``JsonSubsetMatchMetric`` intentionally still operates on the
43
+ unmodified ``expected_output`` vs the unwrapped ``extracted_data``. Accuracy
44
+ will honestly drop on per_table_row runs because scalar fields like
45
+ ``client_id`` may be missing from bare-row predictions. That drop is a
46
+ correct signal for whole-JSON accuracy, and is separate from the rule-level
47
+ P/R/F1 this adapter fixes.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ from collections.abc import Iterable
53
+ from dataclasses import dataclass, field
54
+ from typing import Any
55
+
56
+ from parse_bench.test_cases.extract_field_paths import parse_field_path
57
+ from parse_bench.test_cases.schema import ExtractFieldTestRule
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ListPredictionNormalization:
62
+ """Result of projecting a list-rooted prediction into evaluator shape."""
63
+
64
+ extracted_data: Any
65
+ applied: bool
66
+ mode: str
67
+ skipped_field_paths: list[str] = field(default_factory=list)
68
+ alias_skipped_field_paths: list[str] = field(default_factory=list)
69
+ normalized_top_level_keys: list[str] = field(default_factory=list)
70
+ warnings: list[str] = field(default_factory=list)
71
+
72
+
73
+ def infer_array_field(rules: Iterable[ExtractFieldTestRule]) -> str | None:
74
+ """Return the single top-level array-prefix used by all array-rooted rules.
75
+
76
+ For paths like ``personnel[0].name`` and ``personnel[1].net_pay``, returns
77
+ ``"personnel"``. Returns ``None`` if rules span multiple array prefixes or
78
+ if no rule is array-rooted.
79
+ """
80
+ array_prefixes: set[str] = set()
81
+ for rule in rules:
82
+ try:
83
+ tokens = parse_field_path(rule.field_path)
84
+ except ValueError:
85
+ continue
86
+ # Array-rooted rule: first token is a string, second is an int.
87
+ if len(tokens) >= 2 and isinstance(tokens[0], str) and isinstance(tokens[1], int):
88
+ array_prefixes.add(tokens[0])
89
+ if len(array_prefixes) == 1:
90
+ return next(iter(array_prefixes))
91
+ return None
92
+
93
+
94
+ def _parse_tokens(field_path: str) -> list[str | int] | None:
95
+ try:
96
+ return list(parse_field_path(field_path))
97
+ except ValueError:
98
+ return None
99
+
100
+
101
+ def _top_level_field(field_path: str) -> str | None:
102
+ tokens = _parse_tokens(field_path)
103
+ if tokens and isinstance(tokens[0], str):
104
+ return tokens[0]
105
+ return None
106
+
107
+
108
+ def _is_scalar_rooted_path(field_path: str) -> bool:
109
+ """Return True if ``field_path`` doesn't traverse any array.
110
+
111
+ Top-level scalars (``client_id``) and nested-dict-only paths
112
+ (``buyer.company``) are considered scalar-rooted. Any path containing a
113
+ numeric index token is array-rooted.
114
+ """
115
+ tokens = _parse_tokens(field_path)
116
+ if tokens is None:
117
+ return False
118
+ return not any(isinstance(token, int) for token in tokens)
119
+
120
+
121
+ def _is_array_rooted_path(field_path: str) -> bool:
122
+ tokens = _parse_tokens(field_path)
123
+ return bool(tokens and len(tokens) >= 2 and isinstance(tokens[0], str) and isinstance(tokens[1], int))
124
+
125
+
126
+ def _schema_canonical_key_map(data_schema: dict[str, Any] | None) -> dict[str, str]:
127
+ if not isinstance(data_schema, dict):
128
+ return {}
129
+ properties = data_schema.get("properties")
130
+ if not isinstance(properties, dict):
131
+ return {}
132
+
133
+ by_casefold: dict[str, list[str]] = {}
134
+ for key in properties:
135
+ if isinstance(key, str):
136
+ by_casefold.setdefault(key.casefold(), []).append(key)
137
+ return {folded: keys[0] for folded, keys in by_casefold.items() if len(keys) == 1}
138
+
139
+
140
+ def _canonicalize_key(key: str, canonical_keys: dict[str, str]) -> str:
141
+ return canonical_keys.get(key.casefold(), key)
142
+
143
+
144
+ def _array_prefixes(
145
+ rules: Iterable[ExtractFieldTestRule],
146
+ canonical_keys: dict[str, str],
147
+ ) -> set[str]:
148
+ prefixes: set[str] = set()
149
+ for rule in rules:
150
+ if not _is_array_rooted_path(rule.field_path):
151
+ continue
152
+ top_level = _top_level_field(rule.field_path)
153
+ if top_level is not None:
154
+ prefixes.add(_canonicalize_key(top_level, canonical_keys))
155
+ return prefixes
156
+
157
+
158
+ def _alias_skipped_field_paths(
159
+ rules: Iterable[ExtractFieldTestRule],
160
+ canonical_keys: dict[str, str],
161
+ ) -> list[str]:
162
+ skipped: list[str] = []
163
+ for rule in rules:
164
+ top_level = _top_level_field(rule.field_path)
165
+ if top_level is None:
166
+ continue
167
+ canonical = _canonicalize_key(top_level, canonical_keys)
168
+ if canonical != top_level:
169
+ skipped.append(rule.field_path)
170
+ return skipped
171
+
172
+
173
+ def _all_items_are_dicts(extracted_data: list[Any]) -> bool:
174
+ return all(isinstance(item, dict) for item in extracted_data)
175
+
176
+
177
+ def _has_list_valued_field(extracted_data: list[Any]) -> bool:
178
+ return any(isinstance(value, list) for item in extracted_data if isinstance(item, dict) for value in item.values())
179
+
180
+
181
+ def _merge_wrapper_rows(
182
+ extracted_data: list[Any],
183
+ canonical_keys: dict[str, str],
184
+ ) -> tuple[dict[str, Any], list[str]]:
185
+ merged: dict[str, Any] = {}
186
+ scalar_values: dict[str, Any] = {}
187
+ scalar_conflicts: dict[str, set[str]] = {}
188
+
189
+ for item in extracted_data:
190
+ if not isinstance(item, dict):
191
+ continue
192
+ for raw_key, value in item.items():
193
+ if not isinstance(raw_key, str):
194
+ continue
195
+ key = _canonicalize_key(raw_key, canonical_keys)
196
+ if isinstance(value, list):
197
+ existing = merged.setdefault(key, [])
198
+ if isinstance(existing, list):
199
+ existing.extend(row for row in value if row is not None)
200
+ continue
201
+
202
+ if _is_empty_scalar(value):
203
+ continue
204
+ if key not in scalar_values:
205
+ scalar_values[key] = value
206
+ elif scalar_values[key] != value:
207
+ scalar_conflicts.setdefault(key, {repr(scalar_values[key])}).add(repr(value))
208
+
209
+ for key, value in scalar_values.items():
210
+ merged.setdefault(key, value)
211
+
212
+ warnings = [
213
+ f"conflicting scalar values for {key}: {sorted(values)}" for key, values in sorted(scalar_conflicts.items())
214
+ ]
215
+ return merged, warnings
216
+
217
+
218
+ def _is_empty_scalar(value: Any) -> bool:
219
+ return value is None or value == ""
220
+
221
+
222
+ def normalize_list_prediction(
223
+ extracted_data: Any,
224
+ rules: Iterable[ExtractFieldTestRule],
225
+ *,
226
+ data_schema: dict[str, Any] | None = None,
227
+ ) -> ListPredictionNormalization:
228
+ """Project list-rooted predictions into the dict-rooted evaluator shape."""
229
+ if not isinstance(extracted_data, list):
230
+ return ListPredictionNormalization(
231
+ extracted_data=extracted_data,
232
+ applied=False,
233
+ mode="no_op",
234
+ normalized_top_level_keys=sorted(extracted_data.keys()) if isinstance(extracted_data, dict) else [],
235
+ )
236
+
237
+ rules_list = list(rules)
238
+ canonical_keys = _schema_canonical_key_map(data_schema)
239
+ alias_skipped = _alias_skipped_field_paths(rules_list, canonical_keys)
240
+ alias_skipped_set = set(alias_skipped)
241
+ scoreable_rules = [rule for rule in rules_list if rule.field_path not in alias_skipped_set]
242
+ array_prefixes = _array_prefixes(scoreable_rules, canonical_keys)
243
+
244
+ if not extracted_data:
245
+ if len(array_prefixes) == 1:
246
+ array_field = next(iter(array_prefixes))
247
+ return ListPredictionNormalization(
248
+ extracted_data={array_field: []},
249
+ applied=True,
250
+ mode="bare_rows",
251
+ skipped_field_paths=[
252
+ rule.field_path for rule in scoreable_rules if _is_scalar_rooted_path(rule.field_path)
253
+ ],
254
+ alias_skipped_field_paths=alias_skipped,
255
+ normalized_top_level_keys=[array_field],
256
+ )
257
+ return ListPredictionNormalization(
258
+ extracted_data=extracted_data,
259
+ applied=False,
260
+ mode="no_op",
261
+ alias_skipped_field_paths=alias_skipped,
262
+ )
263
+
264
+ if (
265
+ (rules_list or canonical_keys)
266
+ and not array_prefixes
267
+ and len(extracted_data) == 1
268
+ and isinstance(extracted_data[0], dict)
269
+ and not _has_list_valued_field(extracted_data)
270
+ ):
271
+ normalized = {
272
+ _canonicalize_key(key, canonical_keys): value
273
+ for key, value in extracted_data[0].items()
274
+ if isinstance(key, str)
275
+ }
276
+ return ListPredictionNormalization(
277
+ extracted_data=normalized,
278
+ applied=True,
279
+ mode="singleton_doc",
280
+ alias_skipped_field_paths=alias_skipped,
281
+ normalized_top_level_keys=sorted(normalized.keys()),
282
+ )
283
+
284
+ if _all_items_are_dicts(extracted_data) and _has_list_valued_field(extracted_data):
285
+ merged, warnings = _merge_wrapper_rows(extracted_data, canonical_keys)
286
+ return ListPredictionNormalization(
287
+ extracted_data=merged,
288
+ applied=True,
289
+ mode="wrapper_merge",
290
+ alias_skipped_field_paths=alias_skipped,
291
+ normalized_top_level_keys=sorted(merged.keys()),
292
+ warnings=warnings,
293
+ )
294
+
295
+ if len(array_prefixes) == 1:
296
+ array_field = next(iter(array_prefixes))
297
+ skipped = [rule.field_path for rule in scoreable_rules if _is_scalar_rooted_path(rule.field_path)]
298
+ return ListPredictionNormalization(
299
+ extracted_data={array_field: extracted_data},
300
+ applied=True,
301
+ mode="bare_rows",
302
+ skipped_field_paths=skipped,
303
+ alias_skipped_field_paths=alias_skipped,
304
+ normalized_top_level_keys=[array_field],
305
+ )
306
+
307
+ return ListPredictionNormalization(
308
+ extracted_data=extracted_data,
309
+ applied=False,
310
+ mode="no_op",
311
+ alias_skipped_field_paths=alias_skipped,
312
+ )
313
+
314
+
315
+ def unwrap_list_prediction(
316
+ extracted_data: Any,
317
+ rules: Iterable[ExtractFieldTestRule],
318
+ *,
319
+ data_schema: dict[str, Any] | None = None,
320
+ ) -> tuple[Any, bool, list[str]]:
321
+ """Return ``(wrapped_data, unwrap_applied, skipped_field_paths)``.
322
+
323
+ If ``extracted_data`` is a list and the rules share a single array-prefix,
324
+ return a dict rooted at that prefix. Bare row lists become
325
+ ``{prefix: extracted_data}``; wrapper-per-row lists become
326
+ ``{prefix: flattened_rows}``. Also return the list of field_paths that
327
+ don't touch any array (those can't be scored against a list-rooted
328
+ prediction — caller should exclude them from denominators).
329
+
330
+ If ``extracted_data`` is not a list or no single array-prefix is inferable,
331
+ returns ``(extracted_data, False, [])`` unchanged. In particular, this is
332
+ a no-op for the common per_doc case where predictions are already
333
+ dict-rooted.
334
+ """
335
+ normalized = normalize_list_prediction(extracted_data, rules, data_schema=data_schema)
336
+ return (
337
+ normalized.extracted_data,
338
+ normalized.applied,
339
+ [*normalized.skipped_field_paths, *normalized.alias_skipped_field_paths],
340
+ )
@@ -0,0 +1,90 @@
1
+ """Rule-based metric for executing extract test rules."""
2
+
3
+ from typing import Any
4
+
5
+ from parse_bench.evaluation.metrics.base import Metric
6
+ from parse_bench.evaluation.metrics.extract.test_rules import create_test_rule
7
+ from parse_bench.schemas.evaluation import MetricValue
8
+
9
+
10
+ class ExtractRuleBasedMetric(Metric):
11
+ """Metric for executing test rules against extracted JSON data."""
12
+
13
+ @property
14
+ def name(self) -> str:
15
+ """Return the name of this metric."""
16
+ return "rule_pass_rate"
17
+
18
+ def compute(
19
+ self,
20
+ expected: list[dict[str, Any]] | None,
21
+ actual: dict[str, Any],
22
+ **kwargs: Any,
23
+ ) -> MetricValue:
24
+ """
25
+ Execute test rules against extracted JSON data.
26
+
27
+ :param expected: List of test rule definitions (from test_rules)
28
+ :param actual: Actual extracted JSON data to test
29
+ :param kwargs: Additional parameters (not used)
30
+ :return: MetricValue with pass rate and per-rule results
31
+ """
32
+ if not expected:
33
+ return MetricValue(
34
+ metric_name=self.name,
35
+ value=1.0, # No rules means pass
36
+ metadata={"note": "No test rules provided"},
37
+ )
38
+
39
+ if not actual:
40
+ return MetricValue(
41
+ metric_name=self.name,
42
+ value=0.0,
43
+ metadata={"note": "No extracted data provided"},
44
+ )
45
+
46
+ # Execute each rule
47
+ passed = 0
48
+ total = len(expected)
49
+ rule_results = []
50
+
51
+ for rule_data in expected:
52
+ try:
53
+ rule = create_test_rule(rule_data)
54
+ rule_passed, explanation = rule.run(actual)
55
+ rule_results.append(
56
+ {
57
+ "type": rule_data.get("type"),
58
+ "id": rule_data.get("id"),
59
+ "name": rule_data.get("name"),
60
+ "path": rule_data.get("path"),
61
+ "passed": rule_passed,
62
+ "explanation": explanation,
63
+ }
64
+ )
65
+ if rule_passed:
66
+ passed += 1
67
+ except Exception as e:
68
+ # If rule execution fails, count as failed
69
+ rule_results.append(
70
+ {
71
+ "type": rule_data.get("type"),
72
+ "id": rule_data.get("id"),
73
+ "name": rule_data.get("name"),
74
+ "path": rule_data.get("path"),
75
+ "passed": False,
76
+ "explanation": f"Error executing rule: {e}",
77
+ }
78
+ )
79
+
80
+ pass_rate = passed / total if total > 0 else 0.0
81
+
82
+ return MetricValue(
83
+ metric_name=self.name,
84
+ value=pass_rate,
85
+ metadata={
86
+ "passed": passed,
87
+ "total": total,
88
+ "rule_results": rule_results,
89
+ },
90
+ )