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,237 @@
1
+ """Multiple-choice format sensitivity — does the answer follow content or position?
2
+
3
+ LLMs (and VLMs answering MC benchmarks) carry measurable selection bias: some
4
+ models keep the same LETTER when options are reordered, instead of following
5
+ the option CONTENT. This probe rotates the option list, re-asks, and reports
6
+ per-case columns separating the two failure signatures:
7
+
8
+ format_flip_rate — answers track neither content nor a fixed position
9
+ (brittle / guessing);
10
+ positional_bias — answers keep choosing the same letter across rotations
11
+ (position prior overrides perception).
12
+
13
+ Black-box (``requires=GENERATE``); rotations are deterministic, so held-out
14
+ verification can re-run the probe exactly.
15
+
16
+ References:
17
+ - Large Language Models Are Not Robust Multiple Choice Selectors —
18
+ Zheng et al., ICLR 2024 — arXiv:2309.03882
19
+ - Quantifying Language Models' Sensitivity to Spurious Features in Prompt
20
+ Design (FormatSpread) — Sclar et al., ICLR 2024 — arXiv:2310.11324
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import dataclasses
26
+ import re
27
+ from collections import Counter
28
+ from typing import TYPE_CHECKING, Any, Optional
29
+
30
+ from evalrx.core.analyzer import Analyzer
31
+ from evalrx.core.capability import Capability
32
+ from evalrx.core.registry import register_analyzer
33
+ from evalrx.core.result import Result
34
+
35
+ if TYPE_CHECKING:
36
+ from evalrx.core.case import CaseBatch, FailureCase
37
+ from evalrx.core.model import Model
38
+
39
+ #: Matches ``A. opt``, ``A) opt`` and ``(A) opt``.
40
+ #:
41
+ #: The leading ``\(?`` is not cosmetic. BBH, AGIEval and several MMLU
42
+ #: redistributions write ``(A) blue ball``, and without it the letter is not at
43
+ #: the start of the line, so the whole block matched nothing and the analyzer
44
+ #: reported ``n_scored=0`` on a purely multiple-choice benchmark — a silent
45
+ #: no-op that still spent a generation budget getting there.
46
+ _OPTION_LINE = re.compile(r"^[ \t]*\(?([A-Z])[.)][ \t]+(.+?)[ \t]*$", re.MULTILINE)
47
+ _PAREN_OPTION = re.compile(r"^[ \t]*\([A-Z]\)")
48
+ _MAX_LETTERS = 26
49
+
50
+
51
+ def extract_option_block(prompt: str) -> tuple[list[str], Optional[str]]:
52
+ """The LAST contiguous lettered run ``A. ... B. ...`` in the prompt.
53
+
54
+ A run must start at ``A``, increment by one letter per line, and contain
55
+ only whitespace between consecutive option lines — this keeps few-shot
56
+ examples and restated blocks from being merged into one bogus list.
57
+ Returns ``(options, exact_block_text)`` or ``([], None)``.
58
+ """
59
+ text = str(prompt or "")
60
+ runs: list[list[re.Match]] = []
61
+ current: list[re.Match] = []
62
+ for match in _OPTION_LINE.finditer(text):
63
+ letter = match.group(1)
64
+ if letter == "A":
65
+ current = [match]
66
+ elif (
67
+ current
68
+ and ord(letter) == ord(current[-1].group(1)) + 1
69
+ and not text[current[-1].end():match.start()].strip()
70
+ ):
71
+ current.append(match)
72
+ else:
73
+ current = []
74
+ continue
75
+ if len(current) >= 2:
76
+ runs.append(list(current))
77
+ if not runs:
78
+ return [], None
79
+ best = runs[-1] # final state of the last run
80
+ options = [m.group(2) for m in best]
81
+ return options, text[best[0].start():best[-1].end()]
82
+
83
+
84
+ def extract_options(case: "FailureCase") -> tuple[list[str], Optional[str]]:
85
+ """Options from ``metadata['options']`` (no block text) or the prompt's lettered block."""
86
+ meta = case.metadata.get("options") if isinstance(case.metadata, dict) else None
87
+ if isinstance(meta, (list, tuple)) and 2 <= len(meta) <= _MAX_LETTERS:
88
+ return [str(o) for o in meta], None
89
+ options, block = extract_option_block(case.inputs.prompt or "")
90
+ if not 2 <= len(options) <= _MAX_LETTERS:
91
+ return [], None
92
+ return options, block
93
+
94
+
95
+ def render_options(options: list[str], paren: bool = False) -> str:
96
+ """Render a rotated block, keeping the prompt's own letter style.
97
+
98
+ ``paren`` matters for correctness, not looks: this probe isolates POSITIONAL
99
+ bias by rotating options. Re-rendering a ``(A)`` block as ``A.`` would change
100
+ the delimiter at the same time as the position, so any flip it measured
101
+ could be either cause.
102
+ """
103
+ if paren:
104
+ return "\n".join(f"({chr(65 + i)}) {opt}" for i, opt in enumerate(options))
105
+ return "\n".join(f"{chr(65 + i)}. {opt}" for i, opt in enumerate(options))
106
+
107
+
108
+ def parse_choice(output: str, n_options: int) -> str:
109
+ """Chosen letter: a tagged answer first, else the last standalone CAPITAL.
110
+
111
+ The tag pattern is case-insensitive on the raw text; the fallback scans the
112
+ raw (non-uppercased) output so the article 'a' cannot masquerade as option
113
+ A, and a capital immediately followed by a lowercase word (\"A nice ...\")
114
+ is treated as prose, not a choice.
115
+ """
116
+ n = min(int(n_options), _MAX_LETTERS)
117
+ if n < 1:
118
+ return ""
119
+ last = chr(64 + n) # 'A' + n - 1
120
+ text = str(output or "")
121
+ marked = re.findall(
122
+ # \(? so a model that answers in the prompt's own style -- "Answer: (A)"
123
+ # on a "(A) ..." block -- is read as a tagged answer rather than falling
124
+ # through to the positional fallback below
125
+ rf"(?:answer|choice|option|final)\s*(?:is)?\s*[:=-]?\s*\(?([A-{last}])\b",
126
+ text,
127
+ re.IGNORECASE,
128
+ )
129
+ if marked:
130
+ return marked[-1].upper()
131
+ letters = re.findall(rf"\b([A-{last}])\b(?!\s+[a-z])", text)
132
+ return letters[-1] if letters else ""
133
+
134
+
135
+ @register_analyzer("format_sensitivity")
136
+ class FormatSensitivityAnalyzer(Analyzer):
137
+ """Option-rotation probe: separates positional letter bias from content-tracking on multiple-choice cases.
138
+
139
+ Hyper-parameters:
140
+ n_variants: rotations asked per case (rotation k shifts options by k;
141
+ capped at n_options - 1).
142
+ max_cases: label-stratified cap on probed cases; 0 (the default) = every case.
143
+ """
144
+
145
+ name = "format_sensitivity"
146
+ requires = frozenset({Capability.GENERATE})
147
+ applies_to_modalities = frozenset({"text", "image"})
148
+
149
+ def __init__(self, n_variants: int = 3, max_cases: int = 0) -> None:
150
+ super().__init__(n_variants=max(1, int(n_variants)), max_cases=max_cases)
151
+
152
+ @staticmethod
153
+ def _variant_prompt(
154
+ prompt: str, block: Optional[str], options: list[str], shift: int
155
+ ) -> tuple[str, list[str]]:
156
+ rotated = options[shift:] + options[:shift]
157
+ paren = bool(block) and bool(_PAREN_OPTION.match(block))
158
+ rotated_block = render_options(rotated, paren=paren)
159
+ if block:
160
+ # identical restatements of the same block rotate together
161
+ return prompt.replace(block, rotated_block), rotated
162
+ return (
163
+ f"{prompt}\n\nChoices:\n{rotated_block}\nAnswer with the option letter.",
164
+ rotated,
165
+ )
166
+
167
+ def _run(self, model: "Model", cases: "CaseBatch") -> Result:
168
+ per_case: list[dict[str, Any]] = []
169
+ for case in cases.stratified_head(self.max_cases):
170
+ options, block = extract_options(case)
171
+ entry: dict[str, Any] = {"sample_id": case.id, "n_options": len(options)}
172
+ if len(options) < 2:
173
+ entry["skipped"] = "no multiple-choice options found (2..26 required)"
174
+ per_case.append(entry)
175
+ continue
176
+ prompt = case.inputs.prompt or ""
177
+ shifts = list(range(1, min(self.n_variants, len(options) - 1) + 1))
178
+ chosen_contents: list[str] = []
179
+ chosen_letters: list[str] = []
180
+ n_unparsed = 0
181
+ # Identity variant: reuse the recorded baseline answer only when the
182
+ # prompt contains the lettered block the letters decode against;
183
+ # otherwise every variant (identity included) uses OUR rendered
184
+ # block, so all letters map to options the model actually saw.
185
+ if block is not None and case.observed:
186
+ base_out = str(case.observed)
187
+ else:
188
+ identity_prompt, _ = self._variant_prompt(prompt, block, options, 0)
189
+ base_out = str(
190
+ model.generate(dataclasses.replace(case.inputs, prompt=identity_prompt))
191
+ )
192
+ base_letter = parse_choice(base_out, len(options))
193
+ if base_letter:
194
+ chosen_letters.append(base_letter)
195
+ chosen_contents.append(options[ord(base_letter) - 65])
196
+ else:
197
+ n_unparsed += 1
198
+ for shift in shifts:
199
+ variant_prompt, rotated = self._variant_prompt(prompt, block, options, shift)
200
+ out = str(model.generate(dataclasses.replace(case.inputs, prompt=variant_prompt)))
201
+ letter = parse_choice(out, len(rotated))
202
+ if not letter:
203
+ n_unparsed += 1
204
+ continue
205
+ chosen_letters.append(letter)
206
+ chosen_contents.append(rotated[ord(letter) - 65])
207
+ entry["n_variants"] = 1 + len(shifts)
208
+ entry["n_unparsed"] = n_unparsed
209
+ if chosen_contents:
210
+ content_modal = Counter(chosen_contents).most_common(1)[0][1]
211
+ letter_modal = Counter(chosen_letters).most_common(1)[0]
212
+ entry["format_flip_rate"] = round(1.0 - content_modal / len(chosen_contents), 4)
213
+ entry["positional_bias"] = round(letter_modal[1] / len(chosen_letters), 4)
214
+ entry["modal_letter"] = letter_modal[0]
215
+ per_case.append(entry)
216
+
217
+ flips = [c["format_flip_rate"] for c in per_case if "format_flip_rate" in c]
218
+ letters = Counter(c.get("modal_letter") for c in per_case if c.get("modal_letter"))
219
+ findings: dict[str, Any] = {
220
+ "n_cases": len(per_case),
221
+ "n_scored": len(flips),
222
+ "mean_flip_rate": round(sum(flips) / len(flips), 4) if flips else None,
223
+ "modal_letter_histogram": dict(letters),
224
+ "per_case": per_case,
225
+ "_caveat": (
226
+ "Rotations only (not all permutations): a model biased toward "
227
+ "'the option after the correct one' can evade detection. "
228
+ "positional_bias near 1 with format_flip_rate near 1 is the "
229
+ "letter-prior signature; both near 0 is content-tracking. "
230
+ "Letter parsing prefers tagged answers and ignores capitals "
231
+ "followed by a lowercase word, but free-prose answers can still "
232
+ "be misread — n_unparsed tracks the abstentions. INTERVENTIONAL "
233
+ "columns: held-out verification must RE-RUN the rotations, "
234
+ "never reuse exploration-set values."
235
+ ),
236
+ }
237
+ return Result(analyzer=self.name, model=repr(model), cases=cases, findings=findings)
@@ -0,0 +1,146 @@
1
+ """MM-SHAP — modality-contribution metric, generalised to every media slot.
2
+
3
+ Black-box (``LOGPROBS``): Shapley over the input's text tokens plus one player
4
+ per FILLED media slot (image / audio / video), scored by the model's output
5
+ logprob, then aggregated by modality::
6
+
7
+ <slot>_contribution / (text_contribution + Σ media contributions)
8
+
9
+ 0 for a slot ⇒ the prediction ignores it; 1 ⇒ it carries the prediction alone.
10
+ Measures *reliance*, not correctness — report it as such.
11
+
12
+ The original metric had exactly one non-text player, the image, and this
13
+ implementation hard-coded that: the value function rebuilt ``Inputs(prompt=...,
14
+ image=...)`` and dropped audio and video on the floor. On an audio benchmark it
15
+ therefore reported ``mm_score`` computed over a slot that was never filled — the
16
+ question "is this model actually listening" was unanswerable by the one analyzer
17
+ whose whole job is answering it. Players are now the slots the case fills, so
18
+ an AVLM gets a per-slot breakdown and ``mm_score`` keeps its published meaning
19
+ (image reliance) as one member of it.
20
+
21
+ Paper: "MM-SHAP: A Performance-agnostic Metric for Measuring Multimodal
22
+ Contributions in Vision and Language Models"
23
+ Parcalabescu & Frank, ACL 2022 — https://arxiv.org/abs/2212.08158
24
+ Code: https://github.com/coastalcph/mm-shap
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import TYPE_CHECKING, Callable, Optional
30
+
31
+ from evalrx.analyzers.perturbation._shapley import shapley_values
32
+ from evalrx.core.analyzer import Analyzer
33
+ from evalrx.core.capability import Capability
34
+ from evalrx.core.case import MEDIA_SLOTS, Inputs
35
+ from evalrx.core.registry import register_analyzer
36
+ from evalrx.core.result import Result
37
+
38
+ if TYPE_CHECKING:
39
+ from evalrx.core.case import CaseBatch
40
+ from evalrx.core.model import Model
41
+
42
+ #: Player key for a media slot. Namespaced so it can never collide with a word
43
+ #: index, which is a plain int.
44
+ def _slot_player(slot: str) -> str:
45
+ return f"__{slot}__"
46
+
47
+
48
+ #: Kept for backwards compatibility with callers that imported the old constant.
49
+ _IMAGE = _slot_player("image")
50
+
51
+
52
+ @register_analyzer("mm_shap")
53
+ class MMShapAnalyzer(Analyzer):
54
+ """Per-modality Shapley contribution + the MM-SHAP media-reliance scores."""
55
+
56
+ name = "mm_shap"
57
+ requires = frozenset({Capability.LOGPROBS})
58
+ applies_to_modalities = frozenset({"text", "image", "audio", "video"})
59
+ signal_docs = {
60
+ 'audio_contribution': 'How much the audio drove the answer.',
61
+ 'audio_score': ("Audio's share", "The audio's share of everything that drove the answer."),
62
+ 'has_audio': 'Whether this case had audio at all.',
63
+ 'has_image': 'Whether this case had an image at all.',
64
+ 'has_video': 'Whether this case had video at all.',
65
+ 'image_contribution': 'How much the image drove the answer.',
66
+ 'image_score': ("Image's share", "The image's share of everything that drove the answer."),
67
+ 'media_score': ('Reliance on media', 'How much the answer rested on the pictures and sound together, rather than on the question text alone.'),
68
+ 'mm_score': ('Reliance on the image', 'How much the answer rested on the image, from 0 (all text) to 1 (all image).'),
69
+ 'probed_slots': 'Which modalities were present to weigh.',
70
+ 'text_contribution': ('Reliance on the question', "How much the question's own wording drove the answer."),
71
+ 'video_contribution': 'How much the video drove the answer.',
72
+ 'video_score': ("Video's share", "The video's share of everything that drove the answer."),
73
+ }
74
+ #: Needs at least one media slot filled — with none, every player is a word
75
+ #: and the "modality contribution" it reports is a text-only tautology.
76
+ requires_modalities = frozenset(MEDIA_SLOTS)
77
+
78
+ def __init__(
79
+ self,
80
+ score_fn: Optional[Callable[[Inputs], float]] = None,
81
+ n_samples: int = 64,
82
+ mask_token: str = "___",
83
+ top_k: int = 5,
84
+ seed: int = 0,
85
+ ) -> None:
86
+ super().__init__(score_fn=score_fn, n_samples=n_samples, mask_token=mask_token, top_k=top_k, seed=seed)
87
+
88
+ def _default_scorer(self, model: "Model") -> Callable[[Inputs], float]:
89
+ def score(inputs: Inputs) -> float:
90
+ lps = model.logprobs(inputs)
91
+ return sum(t.logprob for t in lps) / len(lps) if lps else 0.0
92
+ return score
93
+
94
+ def _run(self, model: "Model", cases: "CaseBatch") -> Result:
95
+ case = cases[0]
96
+ words = str(case.inputs.prompt).split()
97
+ filled = [s for s in MEDIA_SLOTS if getattr(case.inputs, s, None) is not None]
98
+ score = self.score_fn or self._default_scorer(model)
99
+
100
+ players: list = list(range(len(words)))
101
+ players += [_slot_player(s) for s in filled]
102
+
103
+ def value(kept: set) -> float:
104
+ masked = " ".join(w if i in kept else self.mask_token for i, w in enumerate(words))
105
+ slots = {
106
+ s: (getattr(case.inputs, s) if _slot_player(s) in kept else None)
107
+ for s in filled
108
+ }
109
+ return score(Inputs(prompt=masked, **slots))
110
+
111
+ shap = shapley_values(players, value, n_samples=self.n_samples, seed=self.seed)
112
+ text_contrib = sum(abs(shap[i]) for i in range(len(words)))
113
+ media_contrib = {s: abs(shap.get(_slot_player(s), 0.0)) for s in filled}
114
+ total = text_contrib + sum(media_contrib.values()) or 1.0
115
+ top_text = sorted(
116
+ ({"token": words[i], "shapley": round(shap[i], 4)} for i in range(len(words))),
117
+ key=lambda d: -abs(d["shapley"]),
118
+ )[: self.top_k]
119
+
120
+ findings = {
121
+ # Published MM-SHAP: image reliance. 0.0 when no image is present,
122
+ # which is why the per-slot fields below carry the real answer for an
123
+ # audio or video run rather than this one.
124
+ "mm_score": round(media_contrib.get("image", 0.0) / total, 4),
125
+ "media_score": round(sum(media_contrib.values()) / total, 4),
126
+ "text_contribution": round(text_contrib, 4),
127
+ "probed_slots": filled,
128
+ "top_text_tokens": top_text,
129
+ "_note": "measures modality reliance, not correctness",
130
+ }
131
+ for slot in MEDIA_SLOTS:
132
+ # Every slot gets a key, present or not: a reader must be able to tell
133
+ # "measured as zero" from "never in this case", and an absent key
134
+ # collapses the two into whatever their default lookup returns.
135
+ findings[f"has_{slot}"] = slot in filled
136
+ findings[f"{slot}_contribution"] = (
137
+ round(media_contrib[slot], 4) if slot in filled else None
138
+ )
139
+ findings[f"{slot}_score"] = (
140
+ round(media_contrib[slot] / total, 4) if slot in filled else None
141
+ )
142
+ return Result(
143
+ analyzer=self.name, model=repr(model), cases=cases,
144
+ artifacts={"shapley": shap},
145
+ findings=findings,
146
+ )
@@ -0,0 +1,196 @@
1
+ """Modality ablation — is the model actually using the modality under test?
2
+
3
+ Black-box (``GENERATE``): for every media slot a case fills, re-ask the same
4
+ question with that slot **ablated**, and compare the answer to the unablated
5
+ baseline. An answer that does not move when the audio is removed or replaced
6
+ was not produced by listening to it.
7
+
8
+ One implementation covers every slot, and that is the point. The question "is
9
+ the image load-bearing" and the question "is the audio load-bearing" are the
10
+ same question asked of a different slot, so a per-modality analyzer would be the
11
+ same code three times, drifting apart — and in practice the audio and video
12
+ copies were simply never written, leaving an ALM run with no way to ask it at
13
+ all. Adding a modality here is adding a member to
14
+ :data:`~evalrx.core.case.MEDIA_SLOTS`.
15
+
16
+ Two ablation modes, because they answer different questions:
17
+
18
+ * ``drop`` (default) — remove the slot entirely. An unchanged answer is strong
19
+ evidence the slot was never read. A *changed* answer proves only sensitivity,
20
+ not correct use: the input shape changed too.
21
+ * ``swap`` — substitute another case's media, keeping the input shape intact and
22
+ breaking only the grounding. Cleaner evidence, and it needs no extra model
23
+ capability, but it requires at least two cases filling the same slot.
24
+
25
+ Reported as reliance, never as correctness. ``ungrounded_rate_<slot>`` is the
26
+ share of probed cases whose answer survived the ablation; ``by_strategy`` carries
27
+ the paired per-case correctness that M2's McNemar reads, which is what makes this
28
+ INTERVENTION-grade rather than one more association.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import dataclasses
34
+ from typing import TYPE_CHECKING, Any, Callable, Optional
35
+
36
+ from evalrx.analyzers.perturbation.prompt_contrast import _default_score, _norm_answer
37
+ from evalrx.core.analyzer import Analyzer
38
+ from evalrx.core.capability import Capability
39
+ from evalrx.core.case import MEDIA_SLOTS
40
+ from evalrx.core.registry import register_analyzer
41
+ from evalrx.core.result import Result
42
+
43
+ if TYPE_CHECKING:
44
+ from evalrx.core.case import CaseBatch, FailureCase
45
+ from evalrx.core.model import Model
46
+
47
+ BASELINE = "baseline"
48
+
49
+
50
+ @register_analyzer("modality_ablation")
51
+ class ModalityAblationAnalyzer(Analyzer):
52
+ """Per-slot grounding probe: does ablating a modality move the answer?"""
53
+
54
+ name = "modality_ablation"
55
+ description = (
56
+ "Re-asks each case with one media slot removed or swapped; an unchanged "
57
+ "answer means the model never used that modality."
58
+ )
59
+ requires = frozenset({Capability.GENERATE})
60
+ applies_to_modalities = frozenset({"text", "image", "audio", "video"})
61
+ signal_docs = {
62
+ 'ablation_audio_is_swap': "The audio was replaced with another case's rather than removed.",
63
+ 'ablation_image_is_swap': "The image was replaced with another case's rather than removed.",
64
+ 'ablation_mode_audio': 'How the audio was removed for these cases.',
65
+ 'ablation_mode_image': 'How the image was removed for these cases.',
66
+ 'ablation_mode_video': 'How the video was removed for these cases.',
67
+ 'ablation_video_is_swap': "The video was replaced with another case's rather than removed.",
68
+ 'grounded_in_audio': ('Actually listened', 'The answer changed when the audio was taken away — the model was listening.'),
69
+ 'grounded_in_image': ('Actually looked', 'The answer changed when the image was taken away — the model was using it.'),
70
+ 'grounded_in_video': ('Actually watched', 'The answer changed when the video was taken away — the model was watching.'),
71
+ 'mode': "How the modality was removed: dropped entirely, or swapped for another case's.",
72
+ 'n_cases_probed': 'How many cases were re-asked with a modality removed.',
73
+ 'n_probed_audio': 'How many cases had audio to test.',
74
+ 'n_probed_image': 'How many cases had an image to test.',
75
+ 'n_probed_video': 'How many cases had video to test.',
76
+ 'probed_slots': 'Which modalities were tested this way.',
77
+ 'ungrounded_rate_audio': ('Ignored the audio', 'Share of cases whose answer did NOT change when the audio was removed — the model was not listening.'),
78
+ 'ungrounded_rate_image': ('Ignored the image', 'Share of cases whose answer did NOT change when the image was removed — the model was not looking.'),
79
+ 'ungrounded_rate_video': ('Ignored the video', 'Share of cases whose answer did NOT change when the video was removed — the model was not watching.'),
80
+ }
81
+ #: Needs a filled slot to ablate. Without one there is nothing to remove and
82
+ #: the analyzer would report a grounding rate over zero probes.
83
+ requires_modalities = frozenset(MEDIA_SLOTS)
84
+
85
+ def __init__(
86
+ self,
87
+ mode: str = "drop",
88
+ slots: "tuple[str, ...] | None" = None,
89
+ score_fn: Optional[Callable[["FailureCase", str], Optional[bool]]] = None,
90
+ max_cases: int = 0,
91
+ ) -> None:
92
+ """
93
+ Args:
94
+ mode: ``"drop"`` (remove the slot) or ``"swap"`` (substitute
95
+ another case's media). ``swap`` falls back to ``drop``
96
+ per-slot when fewer than two cases fill it.
97
+ slots: Restrict to these slots; ``None`` probes every filled one.
98
+ score_fn: ``(case, answer) -> bool | None`` for the paired
99
+ correctness table. Defaults to the prompt-contrast rubric
100
+ scorer; ``None`` verdicts are simply not tabulated.
101
+ max_cases: Cap on cases (cost = 1 + n_filled_slots generations each);
102
+ 0 (the default) = every case.
103
+ """
104
+ if mode not in ("drop", "swap"):
105
+ raise ValueError(f"mode must be 'drop' or 'swap'; got {mode!r}")
106
+ super().__init__(mode=mode, slots=slots, score_fn=score_fn, max_cases=max_cases)
107
+
108
+ # -- helpers -------------------------------------------------------
109
+ def _slots_of(self, case: "FailureCase") -> "list[str]":
110
+ wanted = self.slots or MEDIA_SLOTS
111
+ return [
112
+ s for s in MEDIA_SLOTS
113
+ if s in wanted and getattr(case.inputs, s, None) is not None
114
+ ]
115
+
116
+ @staticmethod
117
+ def _donor(cases: "list[FailureCase]", slot: str, case: "FailureCase") -> Any:
118
+ """Another case's media for *slot*, or ``None`` when there is no donor.
119
+
120
+ Deterministic (first non-self filler in batch order) so a rerun of the
121
+ same batch ablates with the same substitute — a random donor would make
122
+ the paired comparison irreproducible.
123
+ """
124
+ for other in cases:
125
+ if other.id != case.id and getattr(other.inputs, slot, None) is not None:
126
+ return getattr(other.inputs, slot)
127
+ return None
128
+
129
+ def _run(self, model: "Model", cases: "CaseBatch") -> Result:
130
+ score = self.score_fn or _default_score
131
+ selected = cases.stratified_head(self.max_cases)
132
+ selected = [c for c in selected if self._slots_of(c)]
133
+
134
+ by_strategy: dict[str, dict[str, float]] = {BASELINE: {}}
135
+ per_case: list[dict[str, Any]] = []
136
+ # per-slot tallies: probed cases, and those whose answer never moved
137
+ probed = {s: 0 for s in MEDIA_SLOTS}
138
+ ungrounded = {s: 0 for s in MEDIA_SLOTS}
139
+ modes_used: dict[str, str] = {}
140
+
141
+ for case in selected:
142
+ base_answer = str(model.generate(case.inputs))
143
+ base_key = _norm_answer(base_answer)
144
+ base_verdict = score(case, base_answer)
145
+ if base_verdict is not None:
146
+ by_strategy[BASELINE][case.id] = float(bool(base_verdict))
147
+
148
+ row: dict[str, Any] = {"sample_id": case.id}
149
+ for slot in self._slots_of(case):
150
+ donor = self._donor(selected, slot, case) if self.mode == "swap" else None
151
+ # swap with no donor degrades to drop rather than skipping: the
152
+ # weaker evidence is still evidence, and silently probing nothing
153
+ # would look identical to a fully-grounded model.
154
+ effective = "swap" if donor is not None else "drop"
155
+ modes_used[slot] = effective
156
+ ablated = dataclasses.replace(case.inputs, **{slot: donor})
157
+
158
+ answer = str(model.generate(ablated))
159
+ changed = _norm_answer(answer) != base_key
160
+
161
+ probed[slot] += 1
162
+ if not changed:
163
+ ungrounded[slot] += 1
164
+ # Flat scalars only: the per-case harvester reads one level, so a
165
+ # nested {slot: {...}} here would reach no statistic at all.
166
+ row[f"grounded_in_{slot}"] = bool(changed)
167
+ row[f"ablation_{slot}_is_swap"] = effective == "swap"
168
+
169
+ strategy = f"without_{slot}"
170
+ verdict = score(case, answer)
171
+ if verdict is not None:
172
+ by_strategy.setdefault(strategy, {})[case.id] = float(bool(verdict))
173
+ if len(row) > 1:
174
+ per_case.append(row)
175
+
176
+ findings: dict[str, Any] = {
177
+ "n_cases_probed": len(selected),
178
+ "mode": self.mode,
179
+ "probed_slots": sorted(s for s in MEDIA_SLOTS if probed[s]),
180
+ "_note": "measures whether a modality is load-bearing, not correctness",
181
+ "per_case": per_case,
182
+ "by_strategy": by_strategy,
183
+ }
184
+ for slot in MEDIA_SLOTS:
185
+ n = probed[slot]
186
+ # None, not 0.0: "no case filled this slot" and "every case that
187
+ # filled it stayed grounded" are different facts and a reader that
188
+ # cannot separate them will report the wrong one.
189
+ findings[f"n_probed_{slot}"] = n
190
+ findings[f"ungrounded_rate_{slot}"] = round(ungrounded[slot] / n, 4) if n else None
191
+ findings[f"ablation_mode_{slot}"] = modes_used.get(slot)
192
+ return Result(
193
+ analyzer=self.name, model=repr(model), cases=cases,
194
+ findings=findings,
195
+ metadata={"mode": self.mode, "n_selected": len(selected)},
196
+ )