evalrx 0.1.2__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 (339) hide show
  1. evalrx/__init__.py +139 -0
  2. evalrx/agent_assets/__init__.py +2 -0
  3. evalrx/agent_assets/skills/README.md +28 -0
  4. evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
  5. evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
  6. evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
  7. evalrx/agent_assets/skills/nature-figure/README.md +412 -0
  8. evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
  9. evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
  10. evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
  11. evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
  12. evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
  13. evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
  14. evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
  15. evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
  16. evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
  17. evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
  18. evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
  19. evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
  20. evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
  21. evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
  22. evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
  23. evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
  24. evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
  25. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
  26. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
  27. evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
  28. evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
  29. evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
  30. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
  31. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
  32. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
  33. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
  34. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
  35. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
  36. evalrx/agent_assets/skills.py +27 -0
  37. evalrx/agent_runtime/__init__.py +78 -0
  38. evalrx/agent_runtime/_docker_runner.py +89 -0
  39. evalrx/agent_runtime/cli_runtime.py +103 -0
  40. evalrx/agent_runtime/cli_transcript.py +138 -0
  41. evalrx/agent_runtime/cli_types.py +68 -0
  42. evalrx/agent_runtime/codegen/__init__.py +5 -0
  43. evalrx/agent_runtime/codegen/runner.py +94 -0
  44. evalrx/agent_runtime/experiment_harness.py +117 -0
  45. evalrx/agent_runtime/factory.py +102 -0
  46. evalrx/agent_runtime/json_shape.py +44 -0
  47. evalrx/agent_runtime/judges/__init__.py +28 -0
  48. evalrx/agent_runtime/judges/agy.py +179 -0
  49. evalrx/agent_runtime/judges/autodetect.py +135 -0
  50. evalrx/agent_runtime/judges/claude.py +159 -0
  51. evalrx/agent_runtime/judges/codex.py +120 -0
  52. evalrx/agent_runtime/providers/__init__.py +21 -0
  53. evalrx/agent_runtime/providers/antigravity.py +31 -0
  54. evalrx/agent_runtime/providers/base.py +145 -0
  55. evalrx/agent_runtime/providers/claude_code.py +49 -0
  56. evalrx/agent_runtime/providers/codex.py +37 -0
  57. evalrx/agent_runtime/providers/gemini_cli.py +26 -0
  58. evalrx/agent_runtime/providers/kimi_cli.py +27 -0
  59. evalrx/agent_runtime/providers/opencode.py +27 -0
  60. evalrx/agent_runtime/providers/registry.py +58 -0
  61. evalrx/agent_runtime/sandbox.py +517 -0
  62. evalrx/agent_runtime/skill_audit.py +143 -0
  63. evalrx/agent_runtime/skills/__init__.py +19 -0
  64. evalrx/agent_runtime/skills/installer.py +68 -0
  65. evalrx/agent_runtime/skills/prompt_policy.py +86 -0
  66. evalrx/agent_runtime/skills/resolver.py +19 -0
  67. evalrx/analysis/__init__.py +132 -0
  68. evalrx/analysis/adjudicate.py +154 -0
  69. evalrx/analysis/analysis_module.py +361 -0
  70. evalrx/analysis/api.py +171 -0
  71. evalrx/analysis/case_studio.py +651 -0
  72. evalrx/analysis/cli.py +114 -0
  73. evalrx/analysis/dashboard.py +350 -0
  74. evalrx/analysis/eval_case_matrix.py +118 -0
  75. evalrx/analysis/eval_viz_theme.py +833 -0
  76. evalrx/analysis/explore_run.py +333 -0
  77. evalrx/analysis/explorer.py +1276 -0
  78. evalrx/analysis/failure_modes.py +607 -0
  79. evalrx/analysis/fused_pipeline.py +489 -0
  80. evalrx/analysis/holdout.py +300 -0
  81. evalrx/analysis/hypothesis_agent.py +230 -0
  82. evalrx/analysis/narration.py +177 -0
  83. evalrx/analysis/operationalize.py +442 -0
  84. evalrx/analysis/plain_language.py +42 -0
  85. evalrx/analysis/planner.py +283 -0
  86. evalrx/analysis/probe_search.py +203 -0
  87. evalrx/analysis/profile.py +268 -0
  88. evalrx/analysis/prompts/__init__.py +0 -0
  89. evalrx/analysis/prompts/explorer.py +417 -0
  90. evalrx/analysis/prompts/failure_modes.py +33 -0
  91. evalrx/analysis/prompts/holdout.py +27 -0
  92. evalrx/analysis/prompts/hypothesis_agent.py +78 -0
  93. evalrx/analysis/prompts/run_codebase.py +47 -0
  94. evalrx/analysis/prompts/stats_agent.py +72 -0
  95. evalrx/analysis/prompts/stats_tool_generator.py +43 -0
  96. evalrx/analysis/result_marker.py +47 -0
  97. evalrx/analysis/run_codebase.py +242 -0
  98. evalrx/analysis/run_view.py +205 -0
  99. evalrx/analysis/stage_views.py +93 -0
  100. evalrx/analysis/stats_agent.py +944 -0
  101. evalrx/analysis/stats_tool_agent.py +261 -0
  102. evalrx/analysis/stats_tool_generator.py +415 -0
  103. evalrx/analysis/stats_tools.py +1153 -0
  104. evalrx/analysis/trajectory_records.py +193 -0
  105. evalrx/analysis/workbench.py +431 -0
  106. evalrx/analyzers/__init__.py +42 -0
  107. evalrx/analyzers/agent/__init__.py +25 -0
  108. evalrx/analyzers/agent/counterfactual.py +84 -0
  109. evalrx/analyzers/agent/first_error_judge.py +96 -0
  110. evalrx/analyzers/agent/ignored_obs.py +81 -0
  111. evalrx/analyzers/agent/loop_detect.py +79 -0
  112. evalrx/analyzers/agent/reliability.py +165 -0
  113. evalrx/analyzers/agent/tool_shap.py +225 -0
  114. evalrx/analyzers/agent/trajectory_rubric.py +168 -0
  115. evalrx/analyzers/attention/__init__.py +19 -0
  116. evalrx/analyzers/attention/relative_attn.py +610 -0
  117. evalrx/analyzers/attention/rollout.py +73 -0
  118. evalrx/analyzers/attention/sink.py +56 -0
  119. evalrx/analyzers/attention/summary.py +190 -0
  120. evalrx/analyzers/attribution/__init__.py +6 -0
  121. evalrx/analyzers/attribution/generic_attn.py +31 -0
  122. evalrx/analyzers/attribution/gradcam.py +30 -0
  123. evalrx/analyzers/base.py +12 -0
  124. evalrx/analyzers/geometry/__init__.py +6 -0
  125. evalrx/analyzers/geometry/cka.py +70 -0
  126. evalrx/analyzers/geometry/linear_probe.py +157 -0
  127. evalrx/analyzers/hallucination/__init__.py +9 -0
  128. evalrx/analyzers/hallucination/chair.py +78 -0
  129. evalrx/analyzers/hallucination/opera.py +29 -0
  130. evalrx/analyzers/hallucination/pope.py +119 -0
  131. evalrx/analyzers/hallucination/selfcheck.py +155 -0
  132. evalrx/analyzers/hallucination/vcd.py +29 -0
  133. evalrx/analyzers/lens/__init__.py +7 -0
  134. evalrx/analyzers/lens/layer_contrast.py +133 -0
  135. evalrx/analyzers/lens/logit_lens.py +138 -0
  136. evalrx/analyzers/lens/tuned_lens.py +30 -0
  137. evalrx/analyzers/patching/__init__.py +5 -0
  138. evalrx/analyzers/patching/causal_trace.py +30 -0
  139. evalrx/analyzers/perturbation/__init__.py +23 -0
  140. evalrx/analyzers/perturbation/_shapley.py +54 -0
  141. evalrx/analyzers/perturbation/context_shap.py +174 -0
  142. evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
  143. evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
  144. evalrx/analyzers/perturbation/mm_shap.py +146 -0
  145. evalrx/analyzers/perturbation/modality_ablation.py +196 -0
  146. evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
  147. evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
  148. evalrx/analyzers/perturbation/rise.py +94 -0
  149. evalrx/analyzers/perturbation/vl_shap.py +102 -0
  150. evalrx/analyzers/reasoning/__init__.py +33 -0
  151. evalrx/analyzers/reasoning/_text.py +328 -0
  152. evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
  153. evalrx/analyzers/reasoning/arith_audit.py +226 -0
  154. evalrx/analyzers/reasoning/contamination.py +214 -0
  155. evalrx/analyzers/reasoning/knowledge_split.py +253 -0
  156. evalrx/analyzers/reasoning/self_repair.py +246 -0
  157. evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
  158. evalrx/analyzers/reasoning/termination_audit.py +258 -0
  159. evalrx/analyzers/uncertainty/__init__.py +18 -0
  160. evalrx/analyzers/uncertainty/calibration.py +174 -0
  161. evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
  162. evalrx/analyzers/uncertainty/entropy.py +90 -0
  163. evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
  164. evalrx/analyzers/uncertainty/self_consistency.py +204 -0
  165. evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
  166. evalrx/cli.py +411 -0
  167. evalrx/config.py +77 -0
  168. evalrx/contract/__init__.py +179 -0
  169. evalrx/contract/common.py +452 -0
  170. evalrx/contract/emit.py +948 -0
  171. evalrx/contract/export.py +237 -0
  172. evalrx/contract/m1.py +325 -0
  173. evalrx/contract/m2.py +317 -0
  174. evalrx/contract/m3.py +165 -0
  175. evalrx/contract/m4.py +130 -0
  176. evalrx/contract/m5.py +292 -0
  177. evalrx/contract/methodology.py +76 -0
  178. evalrx/contract/pre_m1.py +58 -0
  179. evalrx/contract/typescript.py +140 -0
  180. evalrx/core/__init__.py +85 -0
  181. evalrx/core/analyzer.py +174 -0
  182. evalrx/core/capability.py +54 -0
  183. evalrx/core/case.py +443 -0
  184. evalrx/core/experiment.py +106 -0
  185. evalrx/core/model.py +198 -0
  186. evalrx/core/pipeline.py +42 -0
  187. evalrx/core/registry.py +142 -0
  188. evalrx/core/result.py +64 -0
  189. evalrx/core/spec.py +173 -0
  190. evalrx/core/tokentype.py +165 -0
  191. evalrx/core/tool.py +92 -0
  192. evalrx/datasets/__init__.py +41 -0
  193. evalrx/datasets/base.py +68 -0
  194. evalrx/datasets/gui_os.py +52 -0
  195. evalrx/datasets/llm_qa.py +57 -0
  196. evalrx/datasets/pure_qa.py +12 -0
  197. evalrx/datasets/vlm_qa.py +695 -0
  198. evalrx/datasets/web_search_qa.py +52 -0
  199. evalrx/eval_agent/__init__.py +341 -0
  200. evalrx/eval_agent/_tools.py +81 -0
  201. evalrx/eval_agent/ab_runner.py +50 -0
  202. evalrx/eval_agent/agentic/__init__.py +43 -0
  203. evalrx/eval_agent/agentic/actions.py +216 -0
  204. evalrx/eval_agent/agentic/board.py +107 -0
  205. evalrx/eval_agent/agentic/loop.py +190 -0
  206. evalrx/eval_agent/agentic/tools.py +538 -0
  207. evalrx/eval_agent/checkpoint.py +57 -0
  208. evalrx/eval_agent/cli_agent.py +59 -0
  209. evalrx/eval_agent/cli_skills.py +5 -0
  210. evalrx/eval_agent/evolution.py +396 -0
  211. evalrx/eval_agent/git_manager.py +215 -0
  212. evalrx/eval_agent/hypothesis.py +172 -0
  213. evalrx/eval_agent/label_quarantine.py +209 -0
  214. evalrx/eval_agent/legacy.py +530 -0
  215. evalrx/eval_agent/log_schema.py +497 -0
  216. evalrx/eval_agent/loop.py +2159 -0
  217. evalrx/eval_agent/loop_reports.py +116 -0
  218. evalrx/eval_agent/model_instrumentation.py +282 -0
  219. evalrx/eval_agent/narration.py +193 -0
  220. evalrx/eval_agent/nl_runner.py +460 -0
  221. evalrx/eval_agent/orchestrator.py +61 -0
  222. evalrx/eval_agent/preregister.py +93 -0
  223. evalrx/eval_agent/prompts/__init__.py +1 -0
  224. evalrx/eval_agent/prompts/agentic.py +46 -0
  225. evalrx/eval_agent/prompts/case_discovery.py +25 -0
  226. evalrx/eval_agent/prompts/diagnosis.py +125 -0
  227. evalrx/eval_agent/prompts/experiment_writer.py +265 -0
  228. evalrx/eval_agent/prompts/explore_step.py +37 -0
  229. evalrx/eval_agent/prompts/fix_agent.py +257 -0
  230. evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
  231. evalrx/eval_agent/prompts/nl_runner.py +38 -0
  232. evalrx/eval_agent/prompts/probe_agent.py +25 -0
  233. evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
  234. evalrx/eval_agent/prompts/probe_generator.py +35 -0
  235. evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
  236. evalrx/eval_agent/report.py +58 -0
  237. evalrx/eval_agent/run_context.py +354 -0
  238. evalrx/eval_agent/run_log.schema.json +1215 -0
  239. evalrx/eval_agent/run_logger_v2.py +1764 -0
  240. evalrx/eval_agent/run_metadata.py +208 -0
  241. evalrx/eval_agent/stages/__init__.py +56 -0
  242. evalrx/eval_agent/stages/case_discovery.py +293 -0
  243. evalrx/eval_agent/stages/diagnosis.py +1017 -0
  244. evalrx/eval_agent/stages/experiment_writer.py +1634 -0
  245. evalrx/eval_agent/stages/fix_agent.py +3916 -0
  246. evalrx/eval_agent/stages/fix_internals.py +499 -0
  247. evalrx/eval_agent/stages/fix_pipeline.py +725 -0
  248. evalrx/eval_agent/stages/fix_tiers.py +187 -0
  249. evalrx/eval_agent/stages/fix_tools.py +1034 -0
  250. evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
  251. evalrx/eval_agent/stages/probe.py +439 -0
  252. evalrx/eval_agent/stages/probe_agent.py +1079 -0
  253. evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
  254. evalrx/eval_agent/stages/probe_generator.py +326 -0
  255. evalrx/eval_agent/stages/probe_search_agent.py +106 -0
  256. evalrx/eval_agent/stages/protocol.py +112 -0
  257. evalrx/eval_agent/stages/repair_catalog.py +273 -0
  258. evalrx/eval_agent/stages/surgery.py +524 -0
  259. evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
  260. evalrx/eval_agent/store.py +231 -0
  261. evalrx/logging_utils.py +112 -0
  262. evalrx/models/__init__.py +161 -0
  263. evalrx/models/_discover.py +101 -0
  264. evalrx/models/agent.py +380 -0
  265. evalrx/models/backends/__init__.py +58 -0
  266. evalrx/models/backends/api.py +169 -0
  267. evalrx/models/backends/base.py +57 -0
  268. evalrx/models/backends/gemini_compat.py +579 -0
  269. evalrx/models/backends/hf_local.py +2074 -0
  270. evalrx/models/backends/openai_compat.py +301 -0
  271. evalrx/models/backends/vllm_offline.py +116 -0
  272. evalrx/models/base.py +24 -0
  273. evalrx/models/blackbox/__init__.py +4 -0
  274. evalrx/models/blackbox/agent.py +31 -0
  275. evalrx/models/blackbox/base.py +29 -0
  276. evalrx/models/blackbox/gemini.py +279 -0
  277. evalrx/models/blackbox/llm_api.py +17 -0
  278. evalrx/models/blackbox/vlm_api.py +17 -0
  279. evalrx/models/compose.py +66 -0
  280. evalrx/models/inference.py +88 -0
  281. evalrx/models/paper_methods/__init__.py +8 -0
  282. evalrx/models/paper_methods/aad.py +53 -0
  283. evalrx/models/paper_methods/ifcd.py +204 -0
  284. evalrx/models/paper_methods/pai.py +164 -0
  285. evalrx/models/paper_methods/tcd.py +202 -0
  286. evalrx/models/paper_methods/vcd.py +45 -0
  287. evalrx/models/paper_methods/vicrop.py +137 -0
  288. evalrx/models/toolcodec.py +143 -0
  289. evalrx/models/tools/__init__.py +20 -0
  290. evalrx/models/tools/perception.py +300 -0
  291. evalrx/models/tools/visual.py +174 -0
  292. evalrx/models/whitebox/__init__.py +26 -0
  293. evalrx/models/whitebox/agent.py +31 -0
  294. evalrx/models/whitebox/base.py +24 -0
  295. evalrx/models/whitebox/qwen.py +61 -0
  296. evalrx/models/whitebox/qwen2_5_omni.py +29 -0
  297. evalrx/models/whitebox/qwen2_audio.py +25 -0
  298. evalrx/models/whitebox/qwen_omni.py +53 -0
  299. evalrx/models/whitebox/qwen_vl.py +62 -0
  300. evalrx/observability/__init__.py +21 -0
  301. evalrx/observability/envelope.py +122 -0
  302. evalrx/observability/outbox.py +111 -0
  303. evalrx/observability/tracer.py +882 -0
  304. evalrx/reporting/__init__.py +28 -0
  305. evalrx/reporting/case_study.py +947 -0
  306. evalrx/reporting/compiler.py +587 -0
  307. evalrx/reporting/dynamic.py +1882 -0
  308. evalrx/reporting/html_report.py +2225 -0
  309. evalrx/reporting/langfuse_exporter.py +38 -0
  310. evalrx/reporting/langfuse_source.py +155 -0
  311. evalrx/reporting/model.py +151 -0
  312. evalrx/reporting/run_events.py +184 -0
  313. evalrx/reporting/server.py +557 -0
  314. evalrx/reporting/stages.py +58 -0
  315. evalrx/reporting/static_export.py +142 -0
  316. evalrx/reporting/web_dist/index.html +146 -0
  317. evalrx/specs.py +727 -0
  318. evalrx/stats/__init__.py +47 -0
  319. evalrx/stats/api.py +192 -0
  320. evalrx/stats/bootstrap.py +86 -0
  321. evalrx/stats/ebh.py +27 -0
  322. evalrx/stats/evalue.py +98 -0
  323. evalrx/stats/friedman.py +138 -0
  324. evalrx/stats/mcnemar.py +40 -0
  325. evalrx/stats/multiplicity.py +159 -0
  326. evalrx/stats/subset_sampling.py +55 -0
  327. evalrx/term_links.py +43 -0
  328. evalrx/viz/__init__.py +7 -0
  329. evalrx/viz/labels.py +77 -0
  330. evalrx/viz/prompts.py +39 -0
  331. evalrx/viz/renderer.py +590 -0
  332. evalrx/viz/schema.py +36 -0
  333. evalrx/viz/style.py +134 -0
  334. evalrx-0.1.2.dist-info/METADATA +532 -0
  335. evalrx-0.1.2.dist-info/RECORD +339 -0
  336. evalrx-0.1.2.dist-info/WHEEL +5 -0
  337. evalrx-0.1.2.dist-info/entry_points.txt +3 -0
  338. evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
  339. evalrx-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,695 @@
1
+ """VLM (image + text) QA loaders → CaseBatch.
2
+
3
+ ``VLMQADataset`` is the generic image+text QA loader. ``TextVQASizeDataset``
4
+ wraps TextVQA-style records with answer bounding boxes into the small/medium/
5
+ large partitions used by Zhang et al. (ICLR 2025, arXiv:2502.17422).
6
+ ``Spatial457Dataset`` wraps the **RyanWW/Spatial457** benchmark (6D
7
+ spatial-reasoning VQA) via the HuggingFace ``datasets`` library (optional dep —
8
+ ``pip install evalrx[data]``).
9
+
10
+ The image goes into ``Inputs.image`` (kept OUT of metadata so heavy PIL objects
11
+ aren't duplicated); the gold answer is ``expected``; cases are tagged ``vlm_qa``.
12
+
13
+ Spatial457:
14
+ Paper: "Spatial457: A Diagnostic Benchmark for 6D Spatial Reasoning of Large
15
+ Multimodal Models" — Wang et al., CVPR 2025 — arXiv:2502.08636
16
+ Data: https://huggingface.co/datasets/RyanWW/Spatial457
17
+ Code: https://github.com/XingruiWang/Spatial457
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from collections.abc import Iterable
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from evalrx.core.case import CaseBatch, FailureCase, Inputs, Provenance, Source
28
+ from evalrx.datasets.base import Dataset, read_jsonl
29
+
30
+
31
+ def _vlm_cases(
32
+ records: Iterable[dict],
33
+ *,
34
+ prompt_key: str,
35
+ answer_key: str,
36
+ image_key: str,
37
+ tags: set[str],
38
+ meta_keys: tuple[str, ...] = (),
39
+ base_meta: dict | None = None,
40
+ ) -> CaseBatch:
41
+ """Build image+text cases — image -> Inputs.image (NOT metadata); selected keys -> metadata."""
42
+ out = CaseBatch()
43
+ for rec in records:
44
+ meta = dict(base_meta or {})
45
+ for k in meta_keys:
46
+ if k in rec:
47
+ meta[k] = rec[k]
48
+ out.append(FailureCase(
49
+ inputs=Inputs(prompt=str(rec.get(prompt_key, "")), image=rec.get(image_key)),
50
+ expected=rec.get(answer_key),
51
+ tags=set(tags),
52
+ provenance=Provenance(source=Source.DATASET),
53
+ metadata=meta,
54
+ ))
55
+ return out
56
+
57
+
58
+ _VLM_SAMPLE = [
59
+ {"question": "What is shown in the image?", "answer": "a placeholder", "image": None, "note": "attach a real image"},
60
+ ]
61
+
62
+
63
+ class VLMQADataset(Dataset):
64
+ """Generic image+text question-answering loader."""
65
+
66
+ def __init__(
67
+ self,
68
+ records: list[dict] | None = None,
69
+ path: str | None = None,
70
+ *,
71
+ prompt_key: str = "question",
72
+ answer_key: str = "answer",
73
+ image_key: str = "image",
74
+ meta_keys: tuple[str, ...] = (),
75
+ ) -> None:
76
+ self._records = records
77
+ self._path = path
78
+ self._prompt_key, self._answer_key, self._image_key = prompt_key, answer_key, image_key
79
+ self._meta_keys = meta_keys
80
+
81
+ @classmethod
82
+ def from_records(cls, records: list[dict], **kw) -> "VLMQADataset":
83
+ return cls(records=records, **kw)
84
+
85
+ @classmethod
86
+ def from_jsonl(cls, path: str, **kw) -> "VLMQADataset":
87
+ return cls(path=path, **kw)
88
+
89
+ @classmethod
90
+ def sample(cls) -> "VLMQADataset":
91
+ return cls(records=_VLM_SAMPLE, meta_keys=("note",))
92
+
93
+ def load(self) -> CaseBatch:
94
+ records = self._records if self._records is not None else (
95
+ read_jsonl(self._path) if self._path else _VLM_SAMPLE
96
+ )
97
+ return _vlm_cases(records, prompt_key=self._prompt_key, answer_key=self._answer_key,
98
+ image_key=self._image_key, tags={"vlm_qa"}, meta_keys=self._meta_keys)
99
+
100
+
101
+ # TextVQA size-sensitivity split from arXiv:2502.17422:
102
+ # S = answer_bbox_area / image_area; small < 0.005, medium [0.005, 0.05), large >= 0.05.
103
+ _TEXTVQA_SIZE_THRESHOLDS = {
104
+ "small": (None, 0.005),
105
+ "medium": (0.005, 0.05),
106
+ "large": (0.05, None),
107
+ }
108
+ _TEXTVQA_BBOX_KEYS = (
109
+ "answer_bbox",
110
+ "answer_bboxes",
111
+ "answer_box",
112
+ "answer_boxes",
113
+ "bbox",
114
+ "bboxes",
115
+ "bounding_box",
116
+ "bounding_boxes",
117
+ "ocr_bbox",
118
+ "ocr_bboxes",
119
+ )
120
+
121
+
122
+ def _read_json_or_jsonl(path: str | Path) -> list[dict]:
123
+ p = Path(path)
124
+ if p.suffix.lower() == ".jsonl":
125
+ return read_jsonl(p)
126
+ with open(p, encoding="utf-8") as f:
127
+ data = json.load(f)
128
+ if isinstance(data, dict):
129
+ for key in ("data", "annotations", "questions", "records"):
130
+ if isinstance(data.get(key), list):
131
+ return [r for r in data[key] if isinstance(r, dict)]
132
+ if isinstance(data, list):
133
+ return [r for r in data if isinstance(r, dict)]
134
+ raise ValueError(f"unsupported TextVQA annotation shape in {p}")
135
+
136
+
137
+ def _first_present(rec: dict, keys: tuple[str, ...]) -> Any:
138
+ for key in keys:
139
+ if key in rec and rec[key] not in (None, ""):
140
+ return rec[key]
141
+ return None
142
+
143
+
144
+ def _textvqa_answers(rec: dict) -> list[str]:
145
+ raw = _first_present(rec, ("answers", "valid_answers", "answer", "gold_answer"))
146
+ if raw is None:
147
+ return []
148
+ if isinstance(raw, str):
149
+ return [raw]
150
+ if isinstance(raw, dict):
151
+ value = str(_first_present(raw, ("answer", "text", "word", "value")) or "").strip()
152
+ return [value] if value else []
153
+ out: list[str] = []
154
+ if isinstance(raw, Iterable):
155
+ for item in raw:
156
+ if isinstance(item, str):
157
+ value = item
158
+ elif isinstance(item, dict):
159
+ value = str(_first_present(item, ("answer", "text", "word", "value")) or "")
160
+ else:
161
+ value = str(item)
162
+ value = value.strip()
163
+ if value and value not in out:
164
+ out.append(value)
165
+ return out
166
+
167
+
168
+ def _textvqa_rubric(answers: list[str]) -> str | dict:
169
+ if not answers:
170
+ return ""
171
+ if len(answers) == 1:
172
+ return answers[0]
173
+ return {"any_of": answers}
174
+
175
+
176
+ def _image_value(rec: dict, image_dir: str | Path | None, image_key: str) -> Any:
177
+ image = rec.get(image_key)
178
+ if image is None:
179
+ image = _first_present(rec, ("image_path", "image_file", "file_name", "filename"))
180
+ if isinstance(image, str) and image_dir and not image.startswith(("http://", "https://")):
181
+ p = Path(image)
182
+ if not p.is_absolute():
183
+ image = str(Path(image_dir) / p)
184
+ return image
185
+
186
+
187
+ def _image_size(rec: dict, image: Any) -> tuple[int, int] | None:
188
+ width = _first_present(rec, ("image_width", "width", "w"))
189
+ height = _first_present(rec, ("image_height", "height", "h"))
190
+ if width and height:
191
+ try:
192
+ return int(width), int(height)
193
+ except (TypeError, ValueError):
194
+ pass
195
+ try:
196
+ from PIL import Image
197
+
198
+ if isinstance(image, Image.Image):
199
+ return image.size
200
+ if isinstance(image, str) and image and Path(image).exists():
201
+ with Image.open(image) as img:
202
+ return img.size
203
+ except Exception:
204
+ return None
205
+ return None
206
+
207
+
208
+ def _is_box_like(value: Any) -> bool:
209
+ if isinstance(value, dict):
210
+ keys = set(value)
211
+ return bool({"x", "left", "x1"} & keys) and bool({"y", "top", "y1"} & keys)
212
+ if isinstance(value, (list, tuple)) and len(value) == 4:
213
+ try:
214
+ [float(v) for v in value]
215
+ except (TypeError, ValueError):
216
+ return False
217
+ return True
218
+ return False
219
+
220
+
221
+ def _flatten_boxes(value: Any) -> list[Any]:
222
+ if value is None:
223
+ return []
224
+ if _is_box_like(value):
225
+ return [value]
226
+ if isinstance(value, dict):
227
+ nested = _first_present(value, _TEXTVQA_BBOX_KEYS)
228
+ return _flatten_boxes(nested)
229
+ if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
230
+ out: list[Any] = []
231
+ for item in value:
232
+ out.extend(_flatten_boxes(item))
233
+ return out
234
+ return []
235
+
236
+
237
+ def _bbox_from_ocr_tokens(rec: dict, answers: list[str]) -> list[Any]:
238
+ tokens = _first_present(rec, ("ocr_info", "ocr_tokens", "ocr", "ocr_words"))
239
+ if not isinstance(tokens, Iterable) or isinstance(tokens, (str, bytes)):
240
+ return []
241
+ answer_terms = {a.strip().lower() for a in answers if str(a).strip()}
242
+ answer_words = {
243
+ part
244
+ for answer in answer_terms
245
+ for part in str(answer).replace("/", " ").replace("-", " ").split()
246
+ if part
247
+ }
248
+ out: list[Any] = []
249
+ for token in tokens:
250
+ if not isinstance(token, dict):
251
+ continue
252
+ text = str(_first_present(token, ("text", "word", "ocr_text", "value")) or "").strip().lower()
253
+ if answer_terms and text not in answer_terms and text not in answer_words:
254
+ continue
255
+ out.extend(_flatten_boxes(_first_present(token, _TEXTVQA_BBOX_KEYS)))
256
+ return out
257
+
258
+
259
+ def _box_to_norm_xyxy(
260
+ box: Any,
261
+ *,
262
+ image_size: tuple[int, int],
263
+ bbox_format: str,
264
+ ) -> tuple[float, float, float, float, bool] | None:
265
+ width, height = image_size
266
+ fmt = bbox_format.lower()
267
+ if isinstance(box, dict):
268
+ if {"x1", "y1", "x2", "y2"}.issubset(box):
269
+ vals = [box["x1"], box["y1"], box["x2"], box["y2"]]
270
+ fmt = "xyxy"
271
+ elif {"left", "top", "right", "bottom"}.issubset(box):
272
+ vals = [box["left"], box["top"], box["right"], box["bottom"]]
273
+ fmt = "xyxy"
274
+ elif {"x", "y", "w", "h"}.issubset(box):
275
+ vals = [box["x"], box["y"], box["w"], box["h"]]
276
+ fmt = "xywh"
277
+ elif {"x", "y", "width", "height"}.issubset(box):
278
+ vals = [box["x"], box["y"], box["width"], box["height"]]
279
+ fmt = "xywh"
280
+ else:
281
+ return None
282
+ else:
283
+ try:
284
+ vals = [float(v) for v in box]
285
+ except (TypeError, ValueError):
286
+ return None
287
+ if fmt == "auto":
288
+ if all(0.0 <= v <= 1.0 for v in vals) and vals[2] > vals[0] and vals[3] > vals[1]:
289
+ fmt = "xyxy"
290
+ else:
291
+ fmt = "xywh"
292
+
293
+ try:
294
+ x0, y0, a, b = [float(v) for v in vals]
295
+ except (TypeError, ValueError):
296
+ return None
297
+ normalized = all(0.0 <= v <= 1.0 for v in (x0, y0, a, b))
298
+ if fmt == "xyxy":
299
+ x1, y1, x2, y2 = x0, y0, a, b
300
+ else:
301
+ x1, y1, x2, y2 = x0, y0, x0 + a, y0 + b
302
+ if not normalized:
303
+ x1, x2 = x1 / max(1, width), x2 / max(1, width)
304
+ y1, y2 = y1 / max(1, height), y2 / max(1, height)
305
+ left, right = sorted((max(0.0, min(1.0, x1)), max(0.0, min(1.0, x2))))
306
+ top, bottom = sorted((max(0.0, min(1.0, y1)), max(0.0, min(1.0, y2))))
307
+ degenerate = right <= left or bottom <= top
308
+ if right <= left:
309
+ right = min(1.0, left + 1.0 / max(1, width))
310
+ if right <= left:
311
+ left = max(0.0, right - 1.0 / max(1, width))
312
+ if bottom <= top:
313
+ bottom = min(1.0, top + 1.0 / max(1, height))
314
+ if bottom <= top:
315
+ top = max(0.0, bottom - 1.0 / max(1, height))
316
+ if right <= left or bottom <= top:
317
+ return None
318
+ return left, top, right, bottom, degenerate
319
+
320
+
321
+ def _union_norm_boxes(boxes: list[tuple[float, float, float, float]]) -> tuple[float, float, float, float]:
322
+ left = min(b[0] for b in boxes)
323
+ top = min(b[1] for b in boxes)
324
+ right = max(b[2] for b in boxes)
325
+ bottom = max(b[3] for b in boxes)
326
+ return left, top, right, bottom
327
+
328
+
329
+ def _size_split_for_ratio(ratio: float) -> str:
330
+ if ratio < 0.005:
331
+ return "small"
332
+ if ratio < 0.05:
333
+ return "medium"
334
+ return "large"
335
+
336
+
337
+ def _split_matches(ratio: float, size_split: str) -> bool:
338
+ if size_split == "all":
339
+ return True
340
+ lo, hi = _TEXTVQA_SIZE_THRESHOLDS[size_split]
341
+ return (lo is None or ratio >= lo) and (hi is None or ratio < hi)
342
+
343
+
344
+ class TextVQASizeDataset(Dataset):
345
+ """TextVQA-style VQA records partitioned by answer-bbox relative size.
346
+
347
+ This loader expects local records with a question, one or more accepted
348
+ answers, an image path/object, and an answer bounding box. It supports both
349
+ JSONL and JSON files. The split follows arXiv:2502.17422:
350
+ ``small`` ``S < 0.005``, ``medium`` ``0.005 <= S < 0.05``, and ``large``
351
+ ``S >= 0.05``, where ``S`` is answer bbox area divided by image area.
352
+ """
353
+
354
+ SPLITS = ("small", "medium", "large", "all")
355
+
356
+ def __init__(
357
+ self,
358
+ records: list[dict] | None = None,
359
+ path: str | None = None,
360
+ *,
361
+ image_dir: str | Path | None = None,
362
+ size_split: str = "small",
363
+ max_samples: int | None = None,
364
+ bbox_format: str = "auto",
365
+ question_key: str = "question",
366
+ image_key: str = "image",
367
+ ) -> None:
368
+ if size_split not in self.SPLITS:
369
+ raise ValueError(f"unknown TextVQA size_split {size_split!r}; choose from {self.SPLITS}")
370
+ if bbox_format not in {"auto", "xywh", "xyxy"}:
371
+ raise ValueError("bbox_format must be 'auto', 'xywh', or 'xyxy'")
372
+ self._records = records
373
+ self._path = path
374
+ self.image_dir = image_dir
375
+ self.size_split = size_split
376
+ self.max_samples = max_samples
377
+ self.bbox_format = bbox_format
378
+ self.question_key = question_key
379
+ self.image_key = image_key
380
+
381
+ @classmethod
382
+ def from_records(cls, records: list[dict], **kw) -> "TextVQASizeDataset":
383
+ return cls(records=records, **kw)
384
+
385
+ @classmethod
386
+ def from_jsonl(cls, path: str, **kw) -> "TextVQASizeDataset":
387
+ return cls(path=path, **kw)
388
+
389
+ @classmethod
390
+ def sample(cls) -> "TextVQASizeDataset":
391
+ return cls(records=[
392
+ {
393
+ "question_id": "textvqa_size_sample_0",
394
+ "image": None,
395
+ "image_width": 1000,
396
+ "image_height": 1000,
397
+ "question": "What word is printed on the small sign?",
398
+ "answers": ["moma", "MoMA"],
399
+ "answer_bbox": [100, 120, 40, 12],
400
+ },
401
+ ])
402
+
403
+ def load(self) -> CaseBatch:
404
+ records = self._records if self._records is not None else (
405
+ _read_json_or_jsonl(self._path) if self._path else self.sample()._records
406
+ )
407
+ cases = CaseBatch()
408
+ for idx, rec in enumerate(records or []):
409
+ answers = _textvqa_answers(rec)
410
+ image = _image_value(rec, self.image_dir, self.image_key)
411
+ image_size = _image_size(rec, image)
412
+ if image_size is None:
413
+ continue
414
+ raw_boxes = _flatten_boxes(_first_present(rec, _TEXTVQA_BBOX_KEYS))
415
+ if not raw_boxes:
416
+ raw_boxes = _bbox_from_ocr_tokens(rec, answers)
417
+ parsed_boxes = []
418
+ for raw in raw_boxes:
419
+ parsed = _box_to_norm_xyxy(
420
+ raw,
421
+ image_size=image_size,
422
+ bbox_format=str(rec.get("bbox_format") or self.bbox_format),
423
+ )
424
+ if parsed is not None:
425
+ parsed_boxes.append(parsed)
426
+ norm_boxes = [parsed[:4] for parsed in parsed_boxes]
427
+ if not norm_boxes:
428
+ continue
429
+ bbox = _union_norm_boxes(norm_boxes)
430
+ bbox_degenerate = any(parsed[-1] for parsed in parsed_boxes)
431
+ ratio = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])
432
+ if not _split_matches(ratio, self.size_split):
433
+ continue
434
+ question_id = str(_first_present(rec, ("question_id", "id", "qid")) or f"textvqa_{idx}")
435
+ image_id = _first_present(rec, ("image_id", "image_name", "image_file", "filename"))
436
+ metadata = {
437
+ "dataset": "textvqa",
438
+ "question_id": question_id,
439
+ "image_id": image_id,
440
+ "size_split": _size_split_for_ratio(ratio),
441
+ "requested_size_split": self.size_split,
442
+ "answer_bbox_xyxy_norm": [round(v, 6) for v in bbox],
443
+ "bbox_area_ratio": ratio,
444
+ "bbox_degenerate": bbox_degenerate,
445
+ "answers": answers,
446
+ }
447
+ cases.append(FailureCase(
448
+ id=question_id,
449
+ inputs=Inputs(prompt=str(rec.get(self.question_key, "")), image=image),
450
+ expected=_textvqa_rubric(answers),
451
+ tags={"vlm_qa", "textvqa", f"textvqa_{metadata['size_split']}"},
452
+ provenance=Provenance(source=Source.DATASET),
453
+ metadata=metadata,
454
+ ))
455
+ if self.max_samples is not None and len(cases) >= self.max_samples:
456
+ break
457
+ return cases
458
+
459
+
460
+ # Spatial457 — RyanWW/Spatial457 row schema: {image: PIL, image_filename, question, answer, program, question_index}
461
+ _SPATIAL457_SAMPLE = [
462
+ {"image": None, "image_filename": "superCLEVR_new_000001.png", "question_index": 100001,
463
+ "question": "Is the large red object in front of the yellow car?", "answer": "True"},
464
+ {"image": None, "image_filename": "superCLEVR_new_000002.png", "question_index": 100002,
465
+ "question": "How many objects are behind the blue cube?", "answer": "2"},
466
+ ]
467
+
468
+
469
+ class Spatial457Dataset(Dataset):
470
+ """RyanWW/Spatial457 — 6D spatial-reasoning VQA (CVPR'25), 7 cascading subtypes.
471
+
472
+ ``subset`` selects a difficulty/question-type split. ``load()`` pulls from
473
+ HuggingFace (``pip install evalrx[data]``); use ``from_records`` / ``sample``
474
+ offline. Maps each row to a ``vlm_qa`` FailureCase (image -> Inputs.image,
475
+ answer -> expected; image_filename/question_index/subset -> metadata).
476
+ """
477
+
478
+ SUBTYPES = (
479
+ "L1_single", "L2_objects", "L3_2d_spatial", "L4_occ", "L4_pose",
480
+ "L5_6d_spatial", "L5_collision",
481
+ )
482
+
483
+ def __init__(
484
+ self,
485
+ subset: str = "L5_6d_spatial",
486
+ split: str = "test",
487
+ *,
488
+ records: list[dict] | None = None,
489
+ hf_repo: str = "RyanWW/Spatial457",
490
+ max_samples: int | None = None,
491
+ ) -> None:
492
+ if subset not in self.SUBTYPES:
493
+ raise ValueError(f"unknown Spatial457 subset {subset!r}; choose from {self.SUBTYPES}")
494
+ self.subset = subset
495
+ self.split = split
496
+ self.hf_repo = hf_repo
497
+ self.max_samples = max_samples
498
+ self._records = records
499
+
500
+ @classmethod
501
+ def from_records(cls, records: list[dict], subset: str = "L5_6d_spatial") -> "Spatial457Dataset":
502
+ return cls(subset=subset, records=records)
503
+
504
+ @classmethod
505
+ def sample(cls, subset: str = "L5_6d_spatial") -> "Spatial457Dataset":
506
+ return cls(subset=subset, records=_SPATIAL457_SAMPLE)
507
+
508
+ def _load_hf(self) -> Iterable[dict]:
509
+ try:
510
+ from datasets import load_dataset
511
+ except ImportError as e: # pragma: no cover - optional dep
512
+ raise ImportError(
513
+ "Spatial457Dataset.load() needs the HuggingFace 'datasets' library — "
514
+ "pip install evalrx[data] (or use from_records()/sample() offline)."
515
+ ) from e
516
+ try: # subtypes are exposed as dataset configs
517
+ ds = load_dataset(self.hf_repo, self.subset, split=self.split)
518
+ except Exception: # fall back: single config, filter by a subtype column if present
519
+ ds = load_dataset(self.hf_repo, split=self.split)
520
+ for col in ("question_type", "subtype", "level"):
521
+ if col in (getattr(ds, "column_names", None) or []):
522
+ ds = ds.filter(lambda r, c=col: r.get(c) == self.subset)
523
+ break
524
+ if self.max_samples is not None:
525
+ ds = ds.select(range(min(self.max_samples, len(ds))))
526
+ return ds
527
+
528
+ def load(self) -> CaseBatch:
529
+ rows = self._records if self._records is not None else self._load_hf()
530
+ return _vlm_cases(
531
+ rows, prompt_key="question", answer_key="answer", image_key="image",
532
+ tags={"vlm_qa", "spatial457", self.subset},
533
+ meta_keys=("image_filename", "question_index", "program"),
534
+ base_meta={"dataset": "spatial457", "subset": self.subset},
535
+ )
536
+
537
+
538
+ # VQA-RAD — flaviagiammarino/vqa-rad row schema: {image: PIL, question, answer}
539
+ _VQA_RAD_SAMPLE = [
540
+ {"image": None, "question": "what imaging modality was used?", "answer": "ct"},
541
+ {"image": None, "question": "is there evidence of a pneumothorax?", "answer": "no"},
542
+ {"image": None, "question": "is the heart enlarged?", "answer": "yes"},
543
+ {"image": None, "question": "what plane is this image taken in?", "answer": "axial"},
544
+ ]
545
+
546
+ # Question fragments that identify "easy" identification questions (modality /
547
+ # plane / organ) a general VLM reliably answers — the M4 control (PASS) group.
548
+ _VQA_RAD_EASY_FRAGMENTS = (
549
+ "modality", "what plane", "which plane", "plane is", "what organ",
550
+ "which organ", "organ system", "part of the body", "what type of imaging",
551
+ "what kind of image", "what imaging", "mri or ct", "ct or mri",
552
+ )
553
+
554
+
555
+ def _categorize_vqa_rad(question: str, answer: str) -> str:
556
+ """``"easy"`` (modality/plane/organ), ``"presence"`` (closed yes/no), or ``"other"``."""
557
+ q = " ".join(str(question).lower().split())
558
+ if any(frag in q for frag in _VQA_RAD_EASY_FRAGMENTS):
559
+ return "easy"
560
+ if str(answer).strip().lower() in {"yes", "no"}:
561
+ return "presence"
562
+ return "other"
563
+
564
+
565
+ def _easy_answer_rubric(gold: str) -> dict:
566
+ """Token-level ``any_of`` rubric for open identification answers.
567
+
568
+ VQA-RAD gold strings are messy ("xray - plain film", tab-separated organ
569
+ lists) — whole-string matching would mislabel correct answers as FAIL and
570
+ pollute the control group. Accept any significant gold token instead, with
571
+ a cheap plural tolerance (kidneys → kidney).
572
+ """
573
+ import re as _re
574
+
575
+ tokens: list[str] = []
576
+ for tok in _re.findall(r"[a-z0-9]+", str(gold).lower()):
577
+ if len(tok) < 2 or tok in tokens:
578
+ continue
579
+ tokens.append(tok)
580
+ if tok.endswith("s") and len(tok) > 3 and tok[:-1] not in tokens:
581
+ tokens.append(tok[:-1])
582
+ return {"any_of": tokens or [str(gold).strip().lower()]}
583
+
584
+
585
+ class VQARADDataset(Dataset):
586
+ """VQA-RAD — radiology VQA (Lau et al., 2018), public domain (CC0).
587
+
588
+ Builds a **diagnosis-ready** case mix for the VL failure-analysis loop:
589
+
590
+ - ``n_easy`` identification questions (modality/plane/organ) the model
591
+ reliably PASSES — M4's control group;
592
+ - ``n_presence`` closed yes/no finding-presence questions, balanced between
593
+ gold "yes" and gold "no" — where presence hallucination (yes-bias)
594
+ concentrates the failures.
595
+
596
+ Presence cases carry ``metadata["pope_label"]`` so the ``pope`` analyzer can
597
+ score them and emit per-case ``false_positive`` / ``false_negative``
598
+ mechanism signals, plus a strict ``{"all_of": [gold], "none_of": [other]}``
599
+ rubric for the discovery scorer. Easy cases get a token-level ``any_of``
600
+ rubric (gold strings are messy free text).
601
+
602
+ Data: https://huggingface.co/datasets/flaviagiammarino/vqa-rad
603
+ Paper: "A dataset of clinically generated visual questions and answers
604
+ about radiology images" — Lau et al., Scientific Data 2018.
605
+ """
606
+
607
+ def __init__(
608
+ self,
609
+ split: str = "train",
610
+ *,
611
+ records: list[dict] | None = None,
612
+ hf_repo: str = "flaviagiammarino/vqa-rad",
613
+ n_easy: int = 6,
614
+ n_presence: int = 12,
615
+ seed: int = 0,
616
+ ) -> None:
617
+ self.split = split
618
+ self.hf_repo = hf_repo
619
+ self.n_easy = n_easy
620
+ self.n_presence = n_presence
621
+ self.seed = seed
622
+ self._records = records
623
+
624
+ @classmethod
625
+ def from_records(cls, records: list[dict], **kw) -> "VQARADDataset":
626
+ return cls(records=records, **kw)
627
+
628
+ @classmethod
629
+ def sample(cls) -> "VQARADDataset":
630
+ return cls(records=_VQA_RAD_SAMPLE, n_easy=2, n_presence=2)
631
+
632
+ def _load_hf(self) -> Iterable[dict]:
633
+ try:
634
+ from datasets import load_dataset
635
+ except ImportError as e: # pragma: no cover - optional dep
636
+ raise ImportError(
637
+ "VQARADDataset.load() needs the HuggingFace 'datasets' library — "
638
+ "pip install evalrx[data] (or use from_records()/sample() offline)."
639
+ ) from e
640
+ return load_dataset(self.hf_repo, split=self.split)
641
+
642
+ def load(self) -> CaseBatch:
643
+ import random
644
+
645
+ rows = self._records if self._records is not None else self._load_hf()
646
+
647
+ easy: list[dict] = []
648
+ pres_yes: list[dict] = []
649
+ pres_no: list[dict] = []
650
+ for rec in rows:
651
+ cat = _categorize_vqa_rad(rec.get("question", ""), rec.get("answer", ""))
652
+ if cat == "easy":
653
+ easy.append(rec)
654
+ elif cat == "presence":
655
+ (pres_yes if str(rec["answer"]).strip().lower() == "yes" else pres_no).append(rec)
656
+
657
+ rng = random.Random(self.seed)
658
+ rng.shuffle(easy)
659
+ rng.shuffle(pres_yes)
660
+ rng.shuffle(pres_no)
661
+
662
+ half = self.n_presence // 2
663
+ picked_pres = pres_yes[:half] + pres_no[: self.n_presence - half]
664
+ picked_easy = easy[: self.n_easy]
665
+
666
+ out = CaseBatch()
667
+ for i, rec in enumerate(picked_easy):
668
+ out.append(FailureCase(
669
+ id=f"rad_easy_{i}",
670
+ inputs=Inputs(
671
+ prompt=f"{str(rec['question']).strip()} Answer briefly.",
672
+ image=rec.get("image"),
673
+ ),
674
+ expected=_easy_answer_rubric(rec["answer"]),
675
+ tags={"vlm_qa", "med_vqa", "vqa_rad", "easy"},
676
+ provenance=Provenance(source=Source.DATASET),
677
+ metadata={"dataset": "vqa_rad", "category": "easy",
678
+ "gold_answer": str(rec["answer"]).strip()},
679
+ ))
680
+ for i, rec in enumerate(picked_pres):
681
+ gold = str(rec["answer"]).strip().lower()
682
+ other = "no" if gold == "yes" else "yes"
683
+ out.append(FailureCase(
684
+ id=f"rad_pres_{i}",
685
+ inputs=Inputs(
686
+ prompt=f"{str(rec['question']).strip()} Answer yes or no.",
687
+ image=rec.get("image"),
688
+ ),
689
+ expected={"all_of": [gold], "none_of": [other]},
690
+ tags={"vlm_qa", "med_vqa", "vqa_rad", "presence"},
691
+ provenance=Provenance(source=Source.DATASET),
692
+ metadata={"dataset": "vqa_rad", "category": "presence",
693
+ "pope_label": gold, "gold_answer": gold},
694
+ ))
695
+ return out