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,274 @@
1
+ """Perturbation battery — does the answer track the problem, or the template?
2
+
3
+ GSM-Symbolic's finding is that accuracy on a fixed benchmark hides two opposite
4
+ brittlenesses, and both need the *same* item perturbed rather than a new item:
5
+
6
+ * **invariance breaks** — renaming a person or appending a clause that changes
7
+ nothing flips the answer (the model is keying on surface form),
8
+ * **missing sensitivity** — changing the NUMBERS leaves the answer unmoved
9
+ (the model is reciting a remembered result, not computing one).
10
+
11
+ Neither needs a new gold label, which is what makes the battery cheap: for the
12
+ meaning-preserving arms the answer must not move, for the meaning-altering arm
13
+ it must. The GSM-NoOp arm — one true but irrelevant clause — is the single
14
+ most diagnostic of the set and gets its own column.
15
+
16
+ Cost: 1 + ``len(perturbations)`` generations per case (5 by default).
17
+
18
+ References:
19
+ - GSM-Symbolic: Understanding the Limitations of Mathematical Reasoning in
20
+ Large Language Models — Mirzadeh et al., ICLR 2025 — arXiv:2410.05229
21
+ - Are NLP Models really able to Solve Simple Math Word Problems? — Patel et al.,
22
+ NAACL 2021 — arXiv:2103.07191 (SVAMP: sensitivity to question structure)
23
+ - Adaptive Testing / metamorphic robustness for NLP — Ribeiro et al., ACL 2020
24
+ (CheckList) — arXiv:2005.04118
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import dataclasses
30
+ import re
31
+ from typing import TYPE_CHECKING, Any, Callable, Optional
32
+
33
+ from evalrx.analyzers.reasoning._text import extract_answer, normalize_answer
34
+ from evalrx.core.analyzer import Analyzer
35
+ from evalrx.core.capability import Capability
36
+ from evalrx.core.registry import register_analyzer
37
+ from evalrx.core.result import Result
38
+
39
+ if TYPE_CHECKING:
40
+ from evalrx.core.case import CaseBatch, FailureCase
41
+ from evalrx.core.model import Model
42
+
43
+ #: Replacement names, chosen to be unambiguous and unlikely to appear already.
44
+ _NAME_POOL = ("Kavi", "Rania", "Tomas", "Ingrid", "Naledi", "Yusuf")
45
+ _NAMED_ENTITY = re.compile(r"(?<![.!?]\s)(?<!^)\b([A-Z][a-z]{2,11})\b", re.MULTILINE)
46
+ _INTEGER = re.compile(r"(?<![\w.])(\d{1,6})(?![\w.])")
47
+ _STOP_CAPS = frozenset(
48
+ {
49
+ "The", "This", "That", "These", "Those", "There", "How", "What", "Why",
50
+ "When", "Which", "Who", "If", "Answer", "Question", "Step", "Let", "In",
51
+ "On", "At", "For", "And", "But", "So", "Then", "Each", "Every", "All",
52
+ "Reply", "Give", "Think", "Note", "Total", "First", "Next", "Finally",
53
+ }
54
+ )
55
+ #: A clause that is true, on-topic, and load-bearing for nothing (GSM-NoOp).
56
+ _NOOP_CLAUSE = (
57
+ " Note that last year the same quantities were recorded in a different "
58
+ "notebook, which has since been archived."
59
+ )
60
+
61
+ PRESERVING = "preserving"
62
+ ALTERING = "altering"
63
+
64
+
65
+ def rename_entities(prompt: str) -> Optional[str]:
66
+ """Swap capitalised mid-sentence names for unused ones (meaning-preserving)."""
67
+ found: list[str] = []
68
+ for match in _NAMED_ENTITY.finditer(prompt):
69
+ token = match.group(1)
70
+ if token not in _STOP_CAPS and token not in found:
71
+ found.append(token)
72
+ if not found:
73
+ return None
74
+ mapping = {name: _NAME_POOL[i % len(_NAME_POOL)] for i, name in enumerate(found[:3])}
75
+ out = prompt
76
+ for old, new in mapping.items():
77
+ out = re.sub(rf"\b{re.escape(old)}\b", new, out)
78
+ return out if out != prompt else None
79
+
80
+
81
+ def append_noop_clause(prompt: str) -> Optional[str]:
82
+ """Append one true-but-irrelevant sentence (the GSM-NoOp arm)."""
83
+ lines = prompt.rstrip().splitlines()
84
+ if not lines:
85
+ return None
86
+ # attach to the narrative, not to a trailing instruction line
87
+ idx = 0 if len(lines) == 1 else max(0, len(lines) - 2)
88
+ lines[idx] = lines[idx].rstrip() + _NOOP_CLAUSE
89
+ return "\n".join(lines)
90
+
91
+
92
+ def restate_question(prompt: str) -> Optional[str]:
93
+ """Prepend a neutral framing line (meaning-preserving formatting change)."""
94
+ return f"Read the problem carefully, then solve it.\n\n{prompt}"
95
+
96
+
97
+ def perturb_numbers(prompt: str) -> Optional[str]:
98
+ """Shift every standalone integer (meaning-ALTERING: the answer must move)."""
99
+ seen = 0
100
+
101
+ def _bump(match: re.Match) -> str:
102
+ nonlocal seen
103
+ value = int(match.group(1))
104
+ seen += 1
105
+ # +7 avoids the 0/1 identities and keeps magnitudes plausible
106
+ return str(value + 7 if value >= 2 else value + 3)
107
+
108
+ out = _INTEGER.sub(_bump, prompt)
109
+ return out if seen and out != prompt else None
110
+
111
+
112
+ #: ``(name, kind, fn)`` — kind decides whether a changed answer is a defect.
113
+ DEFAULT_PERTURBATIONS: tuple = (
114
+ ("rename_entities", PRESERVING, rename_entities),
115
+ ("restate_question", PRESERVING, restate_question),
116
+ ("noop_clause", PRESERVING, append_noop_clause),
117
+ ("perturb_numbers", ALTERING, perturb_numbers),
118
+ )
119
+
120
+
121
+ @register_analyzer("perturbation_battery")
122
+ class PerturbationBattery(Analyzer):
123
+ """Metamorphic battery: invariance breaks and missing sensitivity, per case.
124
+
125
+ Hyper-parameters:
126
+ perturbations: ``((name, kind, fn), ...)`` with ``kind`` in
127
+ ``{"preserving", "altering"}``; ``fn(prompt) -> str | None``
128
+ returns ``None`` when it does not apply to that prompt.
129
+ max_cases: label-stratified cap; 0 (the default) = every case.
130
+ gen_kwargs: passed to ``model.generate`` — use temperature 0, or the
131
+ flip rates measure decoding noise instead of brittleness.
132
+ answer_fn: ``callable(text) -> str`` answer extractor.
133
+ """
134
+
135
+ name = "perturbation_battery"
136
+ requires = frozenset({Capability.GENERATE})
137
+ applies_to_modalities = frozenset({"text", "image"})
138
+ signal_docs = {
139
+ 'baseline_answer': 'What the model answered before any perturbation.',
140
+ 'invariance_break_rate': ('Breaks on reworded input', 'For this case: share of meaning-preserving rewordings that changed the answer.'),
141
+ 'mean_invariance_break_rate': ('Breaks on reworded input', 'Share of meaning-PRESERVING rewordings that changed the answer. The question meant the same thing; a high rate means the model is keying on surface form rather than content.'),
142
+ 'n_altering': 'How many meaning-changing edits this case was asked under.',
143
+ 'n_cases': 'How many cases were re-asked under perturbed wordings.',
144
+ 'n_memorization_suspect': ('Suspected recital', 'Cases where changing the actual content left the answer unmoved — the shape of reciting a remembered result rather than working it out.'),
145
+ 'n_preserving': 'How many meaning-preserving rewordings this case was asked under.',
146
+ 'n_scored': 'How many of those could be compared against a baseline answer.',
147
+ 'noop_break_rate': ('Broken by an irrelevant clause', 'Share of cases where adding one true but irrelevant sentence flipped the answer. The single most diagnostic arm of the battery: nothing about the question changed.'),
148
+ 'noop_clause_flipped': ('Flipped by a no-op clause', "Adding one true but irrelevant sentence changed this case's answer."),
149
+ 'restate_question_flipped': ('Flipped by rewording', "Restating the same question in different words changed this case's answer."),
150
+ 'sensitivity_rate': ('Responds to real changes', 'For this case: share of meaning-ALTERING edits that did change the answer. Low is bad — the model ignored a change that should matter.'),
151
+ }
152
+
153
+ def __init__(
154
+ self,
155
+ perturbations: tuple = DEFAULT_PERTURBATIONS,
156
+ max_cases: int = 0,
157
+ gen_kwargs: Optional[dict] = None,
158
+ answer_fn: Optional[Callable[[Any], str]] = None,
159
+ ) -> None:
160
+ super().__init__(max_cases=max_cases, gen_kwargs=dict(gen_kwargs or {}))
161
+ self.perturbations = tuple(perturbations)
162
+ self.answer_fn = answer_fn or extract_answer
163
+
164
+ def _run(self, model: "Model", cases: "CaseBatch") -> Result:
165
+ per_case: list[dict[str, Any]] = []
166
+ for case in cases.stratified_head(self.max_cases):
167
+ per_case.append(self._probe_case(model, case))
168
+
169
+ scored = [c for c in per_case if "invariance_break_rate" in c]
170
+ applied = {name for c in scored for name in c.get("applied", [])}
171
+ findings: dict[str, Any] = {
172
+ "n_cases": len(per_case),
173
+ "n_scored": len(scored),
174
+ "perturbations": [name for name, _, _ in self.perturbations],
175
+ "applied_perturbations": sorted(applied),
176
+ "gen_kwargs": dict(self.gen_kwargs),
177
+ "mean_invariance_break_rate": _mean(
178
+ [c["invariance_break_rate"] for c in scored]
179
+ ),
180
+ "noop_break_rate": _mean(
181
+ [c["noop_clause_flipped"] for c in scored if "noop_clause_flipped" in c]
182
+ ),
183
+ "mean_sensitivity_rate": _mean(
184
+ [c["sensitivity_rate"] for c in scored if c["sensitivity_rate"] is not None]
185
+ ),
186
+ "n_memorization_suspect": sum(c.get("memorization_suspect", 0) for c in scored),
187
+ "per_case": per_case,
188
+ "_caveat": (
189
+ "Two DIFFERENT defects share this probe and must not be summed: "
190
+ "invariance_break_rate should be 0 (a preserving edit moved the "
191
+ "answer) while sensitivity_rate should be 1 (an altering edit "
192
+ "must move it). sensitivity_rate == 0 with a correct baseline is "
193
+ "the memorisation signature — cross-check contamination_score "
194
+ "before calling it reasoning. Every rate is measured against the "
195
+ "model's OWN baseline answer, not the gold, so a case that was "
196
+ "already wrong still contributes: read these as stability, never "
197
+ "as accuracy. Under sampling, a 'flip' can be decoding noise — "
198
+ "set temperature 0 or repeat. INTERVENTIONAL: re-run to confirm."
199
+ ),
200
+ }
201
+ return Result(analyzer=self.name, model=repr(model), cases=cases, findings=findings)
202
+
203
+ # ------------------------------------------------------------------
204
+ def _probe_case(self, model: "Model", case: "FailureCase") -> dict[str, Any]:
205
+ prompt = str(case.inputs.prompt or "")
206
+ entry: dict[str, Any] = {"sample_id": case.id}
207
+ if not prompt.strip():
208
+ entry["skipped"] = "empty prompt"
209
+ return entry
210
+
211
+ # Treat a transient backend failure as missing evidence for this case.
212
+ # Letting it escape aborts the whole battery, which makes the most
213
+ # relevant M1 evidence disappear after just one timed-out audio clip.
214
+ try:
215
+ baseline = normalize_answer(
216
+ self.answer_fn(model.generate(case.inputs, **self.gen_kwargs))
217
+ )
218
+ except Exception as exc:
219
+ entry["skipped"] = f"baseline request failed: {type(exc).__name__}: {str(exc)[:160]}"
220
+ return entry
221
+ entry["baseline_answer"] = baseline[:80]
222
+
223
+ applied: list[str] = []
224
+ preserving_flips: list[int] = []
225
+ altering_flips: list[int] = []
226
+ for name, kind, fn in self.perturbations:
227
+ try:
228
+ variant = fn(prompt)
229
+ except Exception: # pragma: no cover - user-supplied perturbation
230
+ variant = None
231
+ if not variant or variant == prompt:
232
+ continue
233
+ applied.append(name)
234
+ try:
235
+ answer = normalize_answer(
236
+ self.answer_fn(
237
+ model.generate(
238
+ dataclasses.replace(case.inputs, prompt=variant), **self.gen_kwargs
239
+ )
240
+ )
241
+ )
242
+ except Exception as exc:
243
+ entry.setdefault("request_errors", {})[name] = (
244
+ f"{type(exc).__name__}: {str(exc)[:160]}"
245
+ )
246
+ continue
247
+ flipped = int(answer != baseline)
248
+ entry[f"{name}_flipped"] = flipped
249
+ (preserving_flips if kind == PRESERVING else altering_flips).append(flipped)
250
+
251
+ entry["applied"] = applied
252
+ if not applied:
253
+ entry["skipped"] = "no perturbation applies to this prompt"
254
+ return entry
255
+
256
+ entry["invariance_break_rate"] = (
257
+ round(sum(preserving_flips) / len(preserving_flips), 4)
258
+ if preserving_flips
259
+ else 0.0
260
+ )
261
+ entry["n_preserving"] = len(preserving_flips)
262
+ entry["sensitivity_rate"] = (
263
+ round(sum(altering_flips) / len(altering_flips), 4) if altering_flips else None
264
+ )
265
+ entry["n_altering"] = len(altering_flips)
266
+ if altering_flips and sum(altering_flips) == 0:
267
+ # the numbers changed and the answer did not: recall, not computation
268
+ entry["memorization_suspect"] = 1
269
+ return entry
270
+
271
+
272
+ def _mean(values: list) -> Optional[float]:
273
+ clean = [v for v in values if v is not None]
274
+ return round(sum(clean) / len(clean), 4) if clean else None
@@ -0,0 +1,265 @@
1
+ """Prompt-contrast — intervention-grade evidence via paired prompt strategies.
2
+
3
+ Black-box (``GENERATE``): re-asks every case under k prompt strategies (the
4
+ unchanged baseline + targeted rewrites) and scores each answer against the
5
+ case's ``expected`` rubric. The output feeds the paired statistics machinery:
6
+
7
+ - ``findings["by_strategy"] = {strategy: {case_id: 0/1}}`` → M2's
8
+ ``mcnemar_evalue`` (paired, anytime-valid) and ``friedman_nemenyi`` tools;
9
+ - per-case repair flags (``fixed_by_<strategy>``) → ``signal_label_assoc``.
10
+
11
+ This is the harness's *causal* probe for decision-layer failure hypotheses:
12
+ if "describe the region first, then answer" repairs failures, the deficit is in
13
+ answer generation, not perception ("language prior bias"); if an explicit
14
+ "report even subtle findings" instruction repairs them, the model's internal
15
+ yes-threshold is miscalibrated; if nothing helps, the failure sits in visual
16
+ feature discrimination itself.
17
+
18
+ The default strategies are designed to discriminate exactly those mechanisms;
19
+ pass ``strategies={name: template}`` for custom interventions (templates use
20
+ ``{prompt}`` for the original question).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import dataclasses
26
+ import re
27
+ from typing import TYPE_CHECKING, Any, Callable, Optional
28
+
29
+ from evalrx.core.analyzer import Analyzer
30
+ from evalrx.core.capability import Capability
31
+ from evalrx.core.registry import register_analyzer
32
+ from evalrx.core.result import Result
33
+
34
+ if TYPE_CHECKING:
35
+ from evalrx.core.case import CaseBatch, FailureCase
36
+ from evalrx.core.model import Model
37
+
38
+ # Default interventions — chosen to discriminate decision-layer mechanisms.
39
+ _DEFAULT_STRATEGIES: dict[str, str] = {
40
+ "baseline": "{prompt}",
41
+ "describe_first": (
42
+ "First describe what you see in the relevant region of the image in "
43
+ "one sentence, then answer the question. {prompt}"
44
+ ),
45
+ "sensitive": (
46
+ "{prompt} Report a finding as present even if it is subtle — do not "
47
+ "default to a negative answer when uncertain."
48
+ ),
49
+ }
50
+
51
+
52
+ _YESNO_RE = re.compile(r"\b(yes|no)\b")
53
+
54
+
55
+ def _norm_answer(text: str) -> str:
56
+ """Coarse, task-agnostic answer key for cross-prompt agreement.
57
+
58
+ Yes/no answers (POPE-style) collapse to ``yes``/``no``; otherwise the
59
+ lowercased, whitespace-collapsed first 48 chars stand in. Good enough to tell
60
+ "the model said the same thing under every prompt" from "its answer flipped"
61
+ — a correctness-INDEPENDENT measure, so it never tracks the FAIL label."""
62
+ t = re.sub(r"\s+", " ", str(text).strip().lower())
63
+ m = _YESNO_RE.search(t)
64
+ return m.group(1) if m else t[:48]
65
+
66
+
67
+ def _prompt_sensitivity(answers: dict, cid: str, strategies) -> "float | None":
68
+ """Disagreement rate among the strategies' answers for one case.
69
+
70
+ 0.0 = every prompt yielded the same answer (prompt-robust); → 1.0 = the
71
+ answer flips across prompts (prompt-fragile). Independent of whether any
72
+ answer is correct, so it enters M2 as a genuine signal, not a label copy:
73
+ a confident hallucination is robust (≈0), a borderline case is fragile."""
74
+ from collections import Counter
75
+
76
+ vals = [_norm_answer(answers[s][cid]) for s in strategies if cid in answers.get(s, {})]
77
+ if len(vals) < 2:
78
+ return None
79
+ return round(1.0 - Counter(vals).most_common(1)[0][1] / len(vals), 4)
80
+
81
+
82
+ def _word_in(term: str, text: str) -> bool:
83
+ """Word-boundary match for plain alphanumeric terms, substring otherwise."""
84
+ term = term.lower().strip()
85
+ if not term:
86
+ return False
87
+ if re.fullmatch(r"[a-z0-9]+", term):
88
+ return re.search(rf"\b{re.escape(term)}\b", text) is not None
89
+ return term in text
90
+
91
+
92
+ def _default_score(case: "FailureCase", observed: str) -> Optional[bool]:
93
+ """Score *observed* against ``case.expected`` (dict rubric or string).
94
+
95
+ Returns ``True``/``False``, or ``None`` when the case has no usable rubric.
96
+ """
97
+ expected = getattr(case, "expected", None)
98
+ if expected is None:
99
+ return None
100
+ if isinstance(expected, (list, tuple, set, frozenset)):
101
+ from evalrx.analyzers.reasoning._text import default_grader
102
+
103
+ return default_grader(observed, case)
104
+ text = re.sub(r"\s+", " ", str(observed).lower())
105
+ if isinstance(expected, dict):
106
+ if any(_word_in(t, text) for t in expected.get("none_of", [])):
107
+ return False
108
+ if not all(_word_in(t, text) for t in expected.get("all_of", [])):
109
+ return False
110
+ any_of = expected.get("any_of", [])
111
+ if any_of and not any(_word_in(t, text) for t in any_of):
112
+ return False
113
+ return True
114
+ if isinstance(expected, str):
115
+ return _word_in(expected, text)
116
+ return None
117
+
118
+
119
+ @register_analyzer("prompt_contrast")
120
+ class PromptContrastAnalyzer(Analyzer):
121
+ """Paired prompt-strategy contrast over the whole batch.
122
+
123
+ Hyper-parameters:
124
+ strategies: ``{name: template}`` prompt rewrites; templates receive the
125
+ original question via ``{prompt}``. Must include a
126
+ ``"baseline"`` entry (added automatically if missing).
127
+ score_fn: ``(case, answer) -> bool | None`` answer scorer. Defaults
128
+ to a word-boundary rubric scorer over ``case.expected``
129
+ (dict ``all_of/any_of/none_of`` or plain string).
130
+ max_cases: Cap on cases (cost = ``len(strategies)`` generations each); 0 (the default) = every case.
131
+ """
132
+
133
+ name = "prompt_contrast"
134
+ description = (
135
+ "Re-asks each question under paired prompt interventions (baseline / "
136
+ "describe-the-image-first / answer-sensitively) and scores each variant "
137
+ "— causal evidence for whether failures are repairable at the prompt "
138
+ "level (language prior / threshold miscalibration) or persist "
139
+ "regardless (perception failure)."
140
+ )
141
+ requires = frozenset({Capability.GENERATE})
142
+ applies_to_modalities = frozenset({"text", "image"})
143
+ signal_docs = {
144
+ 'n_broken_by_describe_first': ('Broken by describe-first', 'Cases that were right and became wrong. A rewrite that fixes some and breaks others is not an improvement.'),
145
+ 'n_broken_by_sensitive': ('Broken by report-subtle', 'Cases that were right and became wrong under the same instruction.'),
146
+ 'n_cases': 'How many cases were re-asked under each prompt strategy.',
147
+ 'n_fixed_by_describe_first': ('Fixed by describe-first', 'Cases that were wrong and became right when told to describe first.'),
148
+ 'n_fixed_by_sensitive': ('Fixed by report-subtle', 'Cases that were wrong and became right when told to report subtle findings.'),
149
+ 'n_strategies': 'How many prompt strategies were compared, including the unchanged baseline.',
150
+ 'n_unscored': 'Answers that could not be graded, so they enter no comparison.',
151
+ 'prompt_sensitivity': ('Answer moves with wording', "How much this case's answer changed across the prompt rewrites. 0 means every wording produced the same answer; high means the answer depends on how the question was phrased. Independent of whether any answer was correct."),
152
+ 'success_rate_baseline': ('Correct, unchanged prompt', 'Share correct with the original question — the line every rewrite is measured against.'),
153
+ 'success_rate_describe_first': ('Correct, describe-first', 'Share correct when told to describe what it perceives before answering. Higher than baseline means the failure was in answering, not in perceiving.'),
154
+ 'success_rate_sensitive': ('Correct, report-subtle', "Share correct when told to report a finding even if subtle. Higher than baseline means the model's yes/no threshold was set too high, not that it could not tell."),
155
+ }
156
+
157
+ def __init__(
158
+ self,
159
+ strategies: dict[str, str] | None = None,
160
+ score_fn: Callable[[Any, str], Optional[bool]] | None = None,
161
+ # Sized for enriched batches: the per-strategy McNemar is powered by the
162
+ # scarce FAIL group (a strategy can only *flip* an already-failing case),
163
+ # so a small cap leaves too few discordant pairs for M4 to clear the
164
+ # e-value bar. 128 covers typical mined fail counts plus controls.
165
+ max_cases: int = 0,
166
+ ) -> None:
167
+ strategies = dict(strategies) if strategies else dict(_DEFAULT_STRATEGIES)
168
+ if "baseline" not in strategies:
169
+ strategies = {"baseline": "{prompt}", **strategies}
170
+ super().__init__(strategies=strategies, score_fn=score_fn, max_cases=max_cases)
171
+
172
+ def _run(self, model: "Model", cases: "CaseBatch") -> Result:
173
+ score = self.score_fn or _default_score
174
+ by_strategy: dict[str, dict[str, float]] = {s: {} for s in self.strategies}
175
+ answers: dict[str, dict[str, str]] = {s: {} for s in self.strategies}
176
+ n_unscored = 0
177
+
178
+ # Stratified cap: keep FAIL representation so the cases actually carry
179
+ # flippable failures (a plain head is mostly PASS on an enriched batch,
180
+ # or all-FAIL on a fail-first ordering — both lopsided). stratified_head
181
+ # mixes both, which the McNemar (flips) and the no-free-lunch check
182
+ # (breaks of correct answers) each need.
183
+ selected = cases.stratified_head(self.max_cases)
184
+ for case in selected:
185
+ prompt = str(getattr(case.inputs, "prompt", ""))
186
+ for strat, template in self.strategies.items():
187
+ rewritten = template.format(prompt=prompt)
188
+ # dataclasses.replace, not a bare Inputs(prompt=..., image=...):
189
+ # this analyzer is also compatible with audio models (it
190
+ # declares "text" in applies_to_modalities, which intersects
191
+ # any modality set that includes text prompts) — a fresh
192
+ # Inputs(image=...) silently dropped .audio, so the re-ask
193
+ # would have answered without hearing the clip.
194
+ out = str(model.generate(dataclasses.replace(case.inputs, prompt=rewritten)))
195
+ verdict = score(case, out)
196
+ answers[strat][case.id] = out[:200]
197
+ if verdict is None:
198
+ n_unscored += 1
199
+ continue
200
+ by_strategy[strat][case.id] = float(bool(verdict))
201
+
202
+ # Per-case repair/breakage flags relative to baseline — stored in
203
+ # ARTIFACTS, not findings["per_case"]. As label-association signals
204
+ # they are tautological: fixed_by_* can only fire on baseline-FAIL
205
+ # cases and broken_by_* only on baseline-PASS cases, so their sign
206
+ # against the labels is structurally determined and produces spurious
207
+ # SUPPORTED/REFUTED verdicts (same family as the removed
208
+ # baseline_correct flag). The rigorous intervention verdict lives in
209
+ # the paired McNemar over by_strategy; the per-case flags remain
210
+ # available in artifacts for inspection.
211
+ base = by_strategy.get("baseline", {})
212
+ variants = [s for s in self.strategies if s != "baseline"]
213
+ per_case: list[dict[str, Any]] = []
214
+ for case in selected:
215
+ cid = case.id
216
+ if cid not in base:
217
+ continue
218
+ entry: dict[str, Any] = {"sample_id": cid}
219
+ for strat in variants:
220
+ if cid in by_strategy[strat]:
221
+ entry[f"fixed_by_{strat}"] = bool(
222
+ base[cid] == 0.0 and by_strategy[strat][cid] == 1.0
223
+ )
224
+ entry[f"broken_by_{strat}"] = bool(
225
+ base[cid] == 1.0 and by_strategy[strat][cid] == 0.0
226
+ )
227
+ per_case.append(entry)
228
+
229
+ # Per-case signal surfaced to M2 (findings["per_case"]): prompt
230
+ # sensitivity — how much the model's ANSWER flips across the prompt
231
+ # strategies, independent of correctness. Unlike the fixed_by_/broken_by_
232
+ # flags above (tautological, kept in artifacts), this does NOT track the
233
+ # FAIL label, so it can enter signal_label_assoc / rank_corr cleanly.
234
+ findings_per_case: list[dict[str, Any]] = []
235
+ for case in selected:
236
+ sens = _prompt_sensitivity(answers, case.id, self.strategies)
237
+ if sens is not None:
238
+ findings_per_case.append({"sample_id": case.id, "prompt_sensitivity": sens})
239
+
240
+ findings: dict[str, Any] = {
241
+ "n_cases": len(selected),
242
+ "n_strategies": len(self.strategies),
243
+ "n_unscored": n_unscored,
244
+ "by_strategy": by_strategy,
245
+ "per_case": findings_per_case,
246
+ }
247
+ for strat in self.strategies:
248
+ scored = by_strategy[strat]
249
+ if scored:
250
+ findings[f"success_rate_{strat}"] = round(
251
+ sum(scored.values()) / len(scored), 4
252
+ )
253
+ for strat in variants:
254
+ n_fixed = sum(1 for e in per_case if e.get(f"fixed_by_{strat}"))
255
+ n_broken = sum(1 for e in per_case if e.get(f"broken_by_{strat}"))
256
+ findings[f"n_fixed_by_{strat}"] = n_fixed
257
+ findings[f"n_broken_by_{strat}"] = n_broken
258
+
259
+ return Result(
260
+ analyzer=self.name,
261
+ model=repr(model),
262
+ cases=cases,
263
+ findings=findings,
264
+ artifacts={"answers": answers, "per_case": per_case},
265
+ )
@@ -0,0 +1,94 @@
1
+ """RISE — Randomized Input Sampling for Explanation (Petsiuk et al.), text variant.
2
+
3
+ Black-box, model-agnostic: mask random subsets of input tokens, score each masked
4
+ output, and attribute importance to a token as the mean score when it is kept.
5
+ ``requires=GENERATE`` plus an injected ``score_fn(output)->float`` (correctness /
6
+ verifier signal — task-specific, so the caller provides it).
7
+
8
+ Cost note: this is the dominant compute (n_masks forward passes per sample). For
9
+ API models, run it through the existing async caller with an on-disk cache keyed
10
+ by the mask hash so a crashed sweep resumes.
11
+
12
+ References:
13
+ - RISE: Randomized Input Sampling for Explanation of Black-box Models
14
+ Petsiuk, Das & Saenko, BMVC 2018 — arXiv:1806.07421
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import random
20
+ from typing import TYPE_CHECKING, Callable, Optional
21
+
22
+ from evalrx.core.analyzer import Analyzer
23
+ from evalrx.core.capability import Capability
24
+ from evalrx.core.registry import register_analyzer
25
+ from evalrx.core.result import Result
26
+
27
+ if TYPE_CHECKING:
28
+ from evalrx.core.case import CaseBatch
29
+ from evalrx.core.model import Model
30
+
31
+
32
+ @register_analyzer("rise")
33
+ class RISEAnalyzer(Analyzer):
34
+ """Token-occlusion saliency over the input prompt.
35
+
36
+ Hyper-parameters:
37
+ score_fn: ``output_text -> float`` scalar (required).
38
+ n_masks: number of random masks (cost driver).
39
+ keep_prob: probability a token is kept in a mask.
40
+ mask_token: replacement for dropped tokens.
41
+ top_k: tokens to report.
42
+ seed: RNG seed (reproducibility).
43
+ """
44
+
45
+ name = "rise"
46
+ requires = frozenset({Capability.GENERATE})
47
+ applies_to_modalities = frozenset({"text"})
48
+
49
+ def __init__(
50
+ self,
51
+ score_fn: Optional[Callable[[str], float]] = None,
52
+ n_masks: int = 50,
53
+ keep_prob: float = 0.5,
54
+ mask_token: str = "___",
55
+ top_k: int = 5,
56
+ seed: int = 0,
57
+ ) -> None:
58
+ super().__init__(
59
+ score_fn=score_fn, n_masks=n_masks, keep_prob=keep_prob,
60
+ mask_token=mask_token, top_k=top_k, seed=seed,
61
+ )
62
+
63
+ def _run(self, model: "Model", cases: "CaseBatch") -> Result:
64
+ if self.score_fn is None:
65
+ raise ValueError("RISEAnalyzer requires score_fn(output)->float (a task-specific verifier).")
66
+ rng = random.Random(self.seed)
67
+ case = cases[0]
68
+ words = str(case.inputs).split()
69
+ n = len(words)
70
+ sal = [0.0] * n
71
+ cnt = [0] * n
72
+ n_calls = 0
73
+ for _ in range(self.n_masks):
74
+ mask = [rng.random() < self.keep_prob for _ in range(n)]
75
+ if not any(mask):
76
+ continue
77
+ masked = " ".join(w if m else self.mask_token for w, m in zip(words, mask))
78
+ score = float(self.score_fn(model.generate(masked)))
79
+ n_calls += 1
80
+ for j, keep in enumerate(mask):
81
+ if keep:
82
+ sal[j] += score
83
+ cnt[j] += 1
84
+ importance = [(sal[j] / cnt[j]) if cnt[j] else 0.0 for j in range(n)]
85
+ ranked = sorted(zip(words, importance), key=lambda x: -x[1])[: self.top_k]
86
+ return Result(
87
+ analyzer=self.name, model=repr(model), cases=cases,
88
+ artifacts={"importance": importance, "words": words},
89
+ findings={
90
+ "n_words": n,
91
+ "n_masks_run": n_calls,
92
+ "top_tokens": [{"token": w, "importance": round(v, 4)} for w, v in ranked],
93
+ },
94
+ )