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,327 @@
1
+ """Answer-extraction audit — is this a wrong answer, or an unparsed one?
2
+
3
+ **Run this probe first.** Every other per-case column in M1 is conditioned on
4
+ the PASS/FAIL label, and that label comes from a grader that has to find the
5
+ answer inside free-form text. When the grader misses — the model wrote
6
+ ``\\boxed{18}`` and the grader wanted ``Answer: 18``, or it answered in a
7
+ sentence, or the generation was cut off before the tag — the case is labelled
8
+ FAIL for a reason that has nothing to do with the model's reasoning. Those
9
+ cases then enter every downstream correlation as noise pointing at whatever the
10
+ other probes happen to measure, and M2 reports a real-looking effect for a
11
+ harness bug.
12
+
13
+ Costs nothing by default (it reads ``observed``); with ``reask=True`` it spends
14
+ one strict-format generation per suspect case to settle whether the model
15
+ actually had the answer.
16
+
17
+ References:
18
+ - Answer-extraction sensitivity in LLM math evaluation — Language Model
19
+ Evaluation Harness, answer-extraction regex family (EleutherAI, 2023-2025)
20
+ - Let's Verify Step by Step — Lightman et al., ICLR 2024 — arXiv:2305.20050
21
+ (grading protocol for free-form numeric answers)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import dataclasses
27
+ import re
28
+ from typing import TYPE_CHECKING, Any, Callable, Optional
29
+
30
+ from evalrx.analyzers.reasoning._text import (
31
+ _ANSWER_TAG,
32
+ _BOXED,
33
+ answer_equal,
34
+ binary_direction,
35
+ binary_gold,
36
+ extract_answer,
37
+ has_answer_tag,
38
+ looks_like_give_up,
39
+ looks_truncated,
40
+ normalize_answer,
41
+ )
42
+ from evalrx.core.analyzer import Analyzer
43
+ from evalrx.core.capability import Capability, CapabilityError
44
+ from evalrx.core.registry import register_analyzer
45
+ from evalrx.core.result import Result
46
+
47
+ if TYPE_CHECKING:
48
+ from evalrx.core.case import CaseBatch, FailureCase
49
+ from evalrx.core.model import Model
50
+
51
+ _REASK_SUFFIX = (
52
+ "Reply with ONLY the final answer, no explanation, no units, no punctuation."
53
+ )
54
+
55
+
56
+ def _gold_scalar(expected: Any) -> str:
57
+ if isinstance(expected, (list, tuple)):
58
+ return str(expected[0]) if expected else ""
59
+ return str(expected)
60
+
61
+
62
+ def _contract_answer(text: Any, contract: dict[str, Any]) -> str:
63
+ """Return a committed short answer under a benchmark output contract.
64
+
65
+ A single-letter gold must never match the first character of prose such as
66
+ ``Audio Analysis``. We accept an explicit Answer/Final marker or a final
67
+ line containing only the answer, matching the benchmark's response shape.
68
+ """
69
+ raw = str(text or "")
70
+ kind = str(contract.get("kind") or "").lower()
71
+ lines = [line.strip() for line in raw.splitlines() if line.strip()]
72
+ final_line = lines[-1] if lines else raw.strip()
73
+
74
+ if kind == "multiple_choice_letter":
75
+ choices = "".join(str(c).strip()[:1].upper() for c in contract.get("choices", [])) or "ABCD"
76
+ cls = re.escape(choices)
77
+ marked = re.findall(
78
+ rf"(?:\banswer|\bfinal(?:\s+answer)?)\s*(?:is\b\s*|[:=\-]\s*)"
79
+ rf"[\(\[]?([{cls}])[\)\]]?\b",
80
+ raw,
81
+ flags=re.IGNORECASE,
82
+ )
83
+ boxed = re.findall(rf"\\boxed\s*\{{\s*([{cls}])\s*\}}", raw, flags=re.IGNORECASE)
84
+ if marked or boxed:
85
+ return (marked + boxed)[-1].upper()
86
+ bare = re.fullmatch(rf"\s*[\(\[]?([{cls}])[\)\]]?[\s.!]*", final_line, re.IGNORECASE)
87
+ return bare.group(1).upper() if bare else ""
88
+
89
+ if kind == "yes_no":
90
+ marked = re.findall(
91
+ r"(?:\banswer|\bfinal(?:\s+answer)?)\s*(?:is\b\s*|[:=\-]\s*)"
92
+ r"(yes|no)\b",
93
+ raw,
94
+ flags=re.IGNORECASE,
95
+ )
96
+ if marked:
97
+ return marked[-1].capitalize()
98
+ bare = re.fullmatch(r"\s*(yes|no)[\s.!]*", final_line, re.IGNORECASE)
99
+ return bare.group(1).capitalize() if bare else ""
100
+
101
+ return ""
102
+
103
+
104
+ def _contract_anywhere(text: Any, expected: Any, contract: dict[str, Any]) -> bool:
105
+ """Loose upper bound for contracted answers, without substring matching."""
106
+ raw = str(text or "")
107
+ gold = _gold_scalar(expected).strip()
108
+ kind = str(contract.get("kind") or "").lower()
109
+ if _contract_answer(raw, contract).lower() == gold.lower():
110
+ return True
111
+ if kind == "multiple_choice_letter" and len(gold) == 1:
112
+ return bool(re.search(rf"(?<![A-Za-z]){re.escape(gold)}(?![A-Za-z])", raw, re.IGNORECASE))
113
+ if kind == "yes_no":
114
+ return bool(re.search(rf"\b{re.escape(gold)}\b", raw, re.IGNORECASE))
115
+ return False
116
+
117
+
118
+ @register_analyzer("answer_extraction_audit")
119
+ class AnswerExtractionAudit(Analyzer):
120
+ """Split FAIL cases into *really wrong* and *merely unparsed*.
121
+
122
+ Hyper-parameters:
123
+ reask: re-ask each suspect case with a strict output format
124
+ (1 generation per suspect; needs ``GENERATE``).
125
+ max_cases: label-stratified cap; 0 (the default) = every case.
126
+ tail_chars: size of the trailing window treated as the answer region.
127
+ answer_fn: ``callable(text) -> str`` answer extractor.
128
+ match_fn: ``callable(prediction, gold) -> bool`` equality test.
129
+ """
130
+
131
+ name = "answer_extraction_audit"
132
+ # Capability-free on the default (read-only) path so it can audit an
133
+ # offline record dump; the re-ask path checks GENERATE at run time.
134
+ requires = frozenset()
135
+ applies_to_modalities = frozenset({"text", "image"})
136
+ signal_docs = {
137
+ 'answered_yes': ('Model said yes', 'On a yes/no question, whether the MODEL answered yes. Its direction, not whether it was right.'),
138
+ 'gold_yes': ('Correct answer is yes', 'On a yes/no question, whether the CORRECT answer is yes. A property of the question, not the model.'),
139
+ 'extracted_answer': 'The answer the grader pulled out of the text.',
140
+ 'extraction_point_miss': 'The right answer was in the right place and still did not match — a formatting mismatch.',
141
+ 'extraction_suspect': ('Possible grading miss', 'This case was marked wrong, but the right answer is there — a likely grading mistake.'),
142
+ 'gave_up': ('Model gave up', 'Whether the model said it could not answer.'),
143
+ 'gold_in_answer_region': ('Right answer in place', 'Whether the right answer appears in the part of the text the grader reads.'),
144
+ 'gold_in_output': ('Right answer somewhere', 'Whether the right answer appears anywhere in the text, even if not where it was asked for.'),
145
+ 'has_answer_tag': ('Used the answer format', 'Whether the model used the answer format it was asked for.'),
146
+ 'has_output': ('Answered at all', 'Whether the model wrote anything at all.'),
147
+ 'label_disagrees': "The benchmark's verdict and a strict re-check disagree about this case.",
148
+ 'labelled_fail': 'Whether the benchmark marked this answer wrong.',
149
+ 'missing_tag_rate': ('Wrong answer format', 'Share of answers that never used the requested answer format.'),
150
+ 'n_cases': 'How many answers were inspected.',
151
+ 'n_extraction_point_miss': 'Cases where the answer was in the right place but the exact-match check still failed — usually formatting.',
152
+ 'n_extraction_suspect': 'Wrong answers where the right answer was in fact present in the text — the grader may have missed it.',
153
+ 'n_gradable': 'How many answers had a gold answer to check against.',
154
+ 'n_labelled_fail': "How many were marked wrong by the benchmark's own grader.",
155
+ 'n_reask_confirmed': 'How many of those re-asks confirmed the model really was wrong.',
156
+ 'n_reasked': 'How many suspect cases were asked again to check.',
157
+ 'output_chars': ('Answer length', 'How long the answer was, in characters. Very long usually means the model rambled instead of answering.'),
158
+ 'output_truncated': ('Answer was cut off', 'Whether the answer was cut off by the length limit rather than finished.'),
159
+ 'reask_answer': 'What the model said when asked the same question again.',
160
+ 'strict_match': ('Exactly matched gold', 'Whether the answer matched the gold answer exactly.'),
161
+ 'suspect_rate': ('Possible grading misses', 'Share of wrong answers that may be grading mistakes rather than model mistakes. High means the failure numbers are not trustworthy yet.'),
162
+ }
163
+
164
+ def __init__(
165
+ self,
166
+ reask: bool = False,
167
+ max_cases: int = 0,
168
+ tail_chars: int = 200,
169
+ answer_fn: Optional[Callable[[Any], str]] = None,
170
+ match_fn: Optional[Callable[[Any, Any], bool]] = None,
171
+ ) -> None:
172
+ super().__init__(reask=reask, max_cases=max_cases, tail_chars=tail_chars)
173
+ # ctor names, so sklearn-style get_params() reflection works
174
+ self.answer_fn = answer_fn or extract_answer
175
+ self.match_fn = match_fn or answer_equal
176
+
177
+ def _run(self, model: "Model", cases: "CaseBatch") -> Result:
178
+ if self.reask and Capability.GENERATE not in getattr(
179
+ model, "capabilities", frozenset()
180
+ ):
181
+ raise CapabilityError(
182
+ analyzer=self.name, model=repr(model), missing={Capability.GENERATE}
183
+ )
184
+ per_case: list[dict[str, Any]] = []
185
+ n_reasked = 0
186
+ for case in cases.stratified_head(self.max_cases):
187
+ entry = self._audit_case(case)
188
+ if self.reask and entry.get("extraction_suspect") == 1:
189
+ entry.update(self._reask(model, case))
190
+ n_reasked += 1
191
+ per_case.append(entry)
192
+
193
+ gradable = [c for c in per_case if "extraction_suspect" in c]
194
+ failing = [c for c in gradable if c["labelled_fail"] == 1]
195
+ suspects = [c for c in failing if c["extraction_suspect"] == 1]
196
+ confirmed = [c for c in suspects if c.get("reask_correct") == 1]
197
+ suspect_rate = round(len(suspects) / len(failing), 4) if failing else None
198
+
199
+ findings: dict[str, Any] = {
200
+ "n_cases": len(per_case),
201
+ "n_gradable": len(gradable),
202
+ "n_labelled_fail": len(failing),
203
+ "n_extraction_suspect": len(suspects),
204
+ "suspect_rate": suspect_rate,
205
+ "n_reasked": n_reasked,
206
+ "n_reask_confirmed": len(confirmed) if self.reask else None,
207
+ "n_extraction_point_miss": sum(c["extraction_point_miss"] for c in gradable),
208
+ "missing_tag_rate": (
209
+ round(sum(1 - c["has_answer_tag"] for c in gradable) / len(gradable), 4)
210
+ if gradable
211
+ else None
212
+ ),
213
+ "per_case": per_case,
214
+ "_caveat": (
215
+ "suspect_rate is the fraction of FAIL cases whose gold answer "
216
+ "sits in the ANSWER REGION of the output (tail window or a "
217
+ "tagged/boxed span) — HARNESS failures wearing a model-failure "
218
+ "label. gold_in_output is the loose upper bound and over-counts: "
219
+ "it fires on a gold that only appears as an intermediate "
220
+ "quantity, so never read it as the suspect rate. Above ~5% the "
221
+ "labels feeding M2 are contaminated and every other probe's "
222
+ "correlations should be re-read after fixing extraction (or "
223
+ "after dropping extraction_suspect==1 cases). This probe is "
224
+ "OBSERVATIONAL by default: it re-reads stored outputs and "
225
+ "introduces no new sampling. With reask=True the reask_* columns "
226
+ "are interventional and must be re-run for held-out confirmation. "
227
+ "On yes/no (true/false) tasks answered_yes and gold_yes are the "
228
+ "answer's and the gold's DIRECTION, two separate marginals (not "
229
+ "re-grades): a directional hypothesis is tested on them, e.g. "
230
+ "answered_yes HIGHER on failing cases."
231
+ ),
232
+ }
233
+ return Result(analyzer=self.name, model=repr(model), cases=cases, findings=findings)
234
+
235
+ # ------------------------------------------------------------------
236
+ def _audit_case(self, case: "FailureCase") -> dict[str, Any]:
237
+ from evalrx.core.case import Label
238
+
239
+ text = case.observed
240
+ truncated = looks_truncated(text)
241
+ finish_reason = (getattr(case, "metadata", {}) or {}).get("finish_reason")
242
+ if finish_reason == "stop":
243
+ truncated = False
244
+ elif finish_reason == "length":
245
+ truncated = True
246
+ entry: dict[str, Any] = {
247
+ "sample_id": case.id,
248
+ "has_output": int(bool(str(text or "").strip())),
249
+ "has_answer_tag": int(has_answer_tag(text)),
250
+ "output_truncated": int(truncated),
251
+ "gave_up": int(looks_like_give_up(text)),
252
+ "output_chars": len(str(text or "")),
253
+ }
254
+ if case.expected is None or not str(text or "").strip():
255
+ entry["skipped"] = "no gold answer or no stored output"
256
+ return entry
257
+
258
+ raw = str(text)
259
+ metadata = getattr(case, "metadata", {}) or {}
260
+ contract = metadata.get("output_contract") or {}
261
+ contract_kind = str(contract.get("kind") or "").lower()
262
+ contracted = contract_kind in {"multiple_choice_letter", "yes_no"}
263
+ extracted = _contract_answer(raw, contract) if contracted else self.answer_fn(text)
264
+ strict = (
265
+ extracted.lower() == _gold_scalar(case.expected).strip().lower()
266
+ if contracted else bool(self.match_fn(extracted, case.expected))
267
+ )
268
+ gold = normalize_answer(case.expected)
269
+ # Anywhere in the output — the loose UPPER bound; a gold that merely
270
+ # appears as an intermediate quantity counts here and should not.
271
+ anywhere = (
272
+ _contract_anywhere(raw, case.expected, contract)
273
+ if contracted
274
+ else bool(self.match_fn(raw, case.expected)) or gold in normalize_answer(raw)
275
+ )
276
+ # In the answer REGION: the tail of the generation, or any tagged /
277
+ # boxed span. This is the one that means "the model did answer this".
278
+ tail = raw[-self.tail_chars :]
279
+ tagged_spans = " ".join(_BOXED.findall(raw) + _ANSWER_TAG.findall(raw))
280
+ in_tail = (
281
+ _contract_answer(tail, contract).lower() == _gold_scalar(case.expected).strip().lower()
282
+ if contracted
283
+ else bool(self.match_fn(tail, case.expected)) or bool(
284
+ tagged_spans and self.match_fn(tagged_spans, case.expected)
285
+ )
286
+ )
287
+ entry.update(
288
+ {
289
+ "labelled_fail": int(case.label == Label.FAIL),
290
+ "strict_match": int(strict),
291
+ "gold_in_output": int(anywhere),
292
+ "gold_in_answer_region": int(in_tail),
293
+ "extracted_answer": str(extracted)[:120],
294
+ # THE diagnosis: the harness called it a failure, yet the gold
295
+ # answer sits in the answer region of the output.
296
+ "extraction_suspect": int(case.label == Label.FAIL and in_tail),
297
+ # narrower, grader-independent: present in the text but not at
298
+ # the point the extractor reads
299
+ "extraction_point_miss": int(in_tail and not strict),
300
+ # the stored label disagrees with re-grading the stored output
301
+ "label_disagrees": int((case.label == Label.FAIL) == strict),
302
+ }
303
+ )
304
+ # Binary tasks only: the DIRECTION of the gold and of the answer, as two
305
+ # separate marginals. Neither is a function of the label -- answered_yes
306
+ # is model behaviour, gold_yes is a question covariate -- so both may
307
+ # enter M2's tested family, and M4 can check a directional hypothesis
308
+ # ("a Yes prior: answered_yes HIGHER on failures; failures concentrate
309
+ # on gold_yes=0"). Their CONJUNCTION (answered Yes on a gold No) is a
310
+ # subset of FAIL by construction and must never be a column here.
311
+ # Live motivation: audiocaps_hallucination 2026-08-20, where M3 named
312
+ # extracted_answer / labelled_fail and M4 had nothing numeric to test.
313
+ gold_dir = binary_gold(case.expected)
314
+ if gold_dir is not None:
315
+ entry["gold_yes"] = int(gold_dir == "yes")
316
+ answer_dir = binary_direction(extracted) or binary_direction(tail, last=True)
317
+ if answer_dir is not None:
318
+ entry["answered_yes"] = int(answer_dir == "yes")
319
+ return entry
320
+
321
+ def _reask(self, model: "Model", case: "FailureCase") -> dict[str, Any]:
322
+ prompt = f"{case.inputs.prompt or ''}\n\n{_REASK_SUFFIX}"
323
+ answer = str(model.generate(dataclasses.replace(case.inputs, prompt=prompt)))
324
+ return {
325
+ "reask_answer": answer[:120],
326
+ "reask_correct": int(bool(self.match_fn(answer, case.expected))),
327
+ }
@@ -0,0 +1,226 @@
1
+ """Arithmetic audit — computation slip or broken reasoning chain?
2
+
3
+ "The model is bad at math" is two failures wearing one label. Either the plan
4
+ was right and one multiplication came out wrong (a *computation slip*, fixed by
5
+ a calculator tool or a verification pass), or every stated computation checks
6
+ out and the answer is still wrong (a *chain break*, fixed by better
7
+ decomposition — a calculator changes nothing). M5 picks opposite tiers for the
8
+ two, so collapsing them wastes the whole arc.
9
+
10
+ The split needs **no extra generations**: every ``a op b = c`` statement in the
11
+ stored reasoning is re-evaluated, and the first wrong one is forward-substituted
12
+ — if repairing that single value reproduces the gold answer (directly, or by
13
+ propagating the difference/ratio to the model's own final number), the error is
14
+ a slip; if the arithmetic is clean and the answer is wrong, the chain broke.
15
+
16
+ References:
17
+ - Training Verifiers to Solve Math Word Problems — Cobbe et al., 2021 —
18
+ arXiv:2110.14168 (calculator annotations: arithmetic vs planning errors)
19
+ - Let's Verify Step by Step — Lightman et al., ICLR 2024 — arXiv:2305.20050
20
+ - GSM-Symbolic: Understanding the Limitations of Mathematical Reasoning in LLMs
21
+ — Mirzadeh et al., ICLR 2025 — arXiv:2410.05229
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import dataclasses
27
+ from typing import TYPE_CHECKING, Any, Callable, Optional
28
+
29
+ from evalrx.analyzers.reasoning._text import (
30
+ answer_equal,
31
+ as_number,
32
+ extract_answer,
33
+ find_equations,
34
+ numbers_in,
35
+ )
36
+ from evalrx.core.analyzer import Analyzer
37
+ from evalrx.core.capability import Capability, CapabilityError
38
+ from evalrx.core.registry import register_analyzer
39
+ from evalrx.core.result import Result
40
+
41
+ if TYPE_CHECKING:
42
+ from evalrx.core.case import CaseBatch, FailureCase
43
+ from evalrx.core.model import Model
44
+
45
+ _COT_SUFFIX = (
46
+ "Show every calculation as an explicit 'a op b = c' line. After the "
47
+ "reasoning, give the final answer on its own last line as 'Answer: <answer>'."
48
+ )
49
+
50
+ SLIP = "computation_slip"
51
+ CHAIN_BREAK = "chain_break"
52
+ COMPOUND = "compound"
53
+ CLEAN = "clean"
54
+ LUCKY = "errors_but_correct"
55
+ UNKNOWN = "unknown"
56
+
57
+
58
+ def _close(a: float, b: float, rel_tol: float) -> bool:
59
+ return abs(a - b) <= rel_tol * max(1.0, abs(b))
60
+
61
+
62
+ @register_analyzer("arith_audit")
63
+ class ArithmeticAudit(Analyzer):
64
+ """Re-check every arithmetic step in the reasoning and classify the failure.
65
+
66
+ Hyper-parameters:
67
+ rel_tol: relative tolerance for "the stated value is right".
68
+ generate_missing: generate a chain when ``case.observed`` is empty
69
+ (1 generation; needs ``GENERATE``). Off by default —
70
+ the probe is meant to be free.
71
+ max_cases: label-stratified cap; 0 (the default) = every case.
72
+ answer_fn/match_fn: answer extraction / equality (see ``_text``).
73
+ """
74
+
75
+ name = "arith_audit"
76
+ # Free and capability-free on the default path (it re-reads stored chains);
77
+ # the generate_missing path checks GENERATE at run time.
78
+ requires = frozenset()
79
+ applies_to_modalities = frozenset({"text", "image"})
80
+
81
+ def __init__(
82
+ self,
83
+ rel_tol: float = 1e-6,
84
+ generate_missing: bool = False,
85
+ max_cases: int = 0,
86
+ answer_fn: Optional[Callable[[Any], str]] = None,
87
+ match_fn: Optional[Callable[[Any, Any], bool]] = None,
88
+ ) -> None:
89
+ super().__init__(
90
+ rel_tol=rel_tol, generate_missing=generate_missing, max_cases=max_cases
91
+ )
92
+ self.answer_fn = answer_fn or extract_answer
93
+ self.match_fn = match_fn or answer_equal
94
+
95
+ def _run(self, model: "Model", cases: "CaseBatch") -> Result:
96
+ if self.generate_missing and Capability.GENERATE not in getattr(
97
+ model, "capabilities", frozenset()
98
+ ):
99
+ raise CapabilityError(
100
+ analyzer=self.name, model=repr(model), missing={Capability.GENERATE}
101
+ )
102
+ per_case: list[dict[str, Any]] = []
103
+ for case in cases.stratified_head(self.max_cases):
104
+ text = case.observed
105
+ if not str(text or "").strip() and self.generate_missing:
106
+ prompt = f"{case.inputs.prompt or ''}\n\n{_COT_SUFFIX}"
107
+ text = str(model.generate(dataclasses.replace(case.inputs, prompt=prompt)))
108
+ per_case.append(self._audit(case, text))
109
+
110
+ classified = [c for c in per_case if c.get("error_class") not in (None, UNKNOWN)]
111
+ wrong = [c for c in classified if c["answer_correct"] == 0]
112
+ checked = [c for c in per_case if c.get("n_equations", 0) > 0]
113
+
114
+ def _rate(cls: str) -> Optional[float]:
115
+ return round(sum(1 for c in wrong if c["error_class"] == cls) / len(wrong), 4) \
116
+ if wrong else None
117
+
118
+ findings: dict[str, Any] = {
119
+ "n_cases": len(per_case),
120
+ "n_with_equations": len(checked),
121
+ "n_wrong_answers": len(wrong),
122
+ "mean_arith_error_rate": (
123
+ round(sum(c["arith_error_rate"] for c in checked) / len(checked), 4)
124
+ if checked
125
+ else None
126
+ ),
127
+ "computation_slip_rate": _rate(SLIP),
128
+ "chain_break_rate": _rate(CHAIN_BREAK),
129
+ "compound_rate": _rate(COMPOUND),
130
+ "n_errors_but_correct": sum(1 for c in classified if c["error_class"] == LUCKY),
131
+ "per_case": per_case,
132
+ "_caveat": (
133
+ "The slip/chain-break split is only defined over cases whose "
134
+ "reasoning actually WRITES its arithmetic: n_equations==0 means "
135
+ "'not measured', not 'no arithmetic errors' — a model that "
136
+ "computes silently is unmeasurable here, so read "
137
+ "n_with_equations before the rates. computation_slip ⇒ an L1 "
138
+ "tool/verification fix; chain_break ⇒ decomposition, where a "
139
+ "calculator changes nothing. errors_but_correct is its own "
140
+ "signal: arithmetic that is wrong yet lands on the gold answer "
141
+ "means the written chain is decorative (cross-check with "
142
+ "cot_faithfulness). OBSERVATIONAL — it re-reads stored outputs "
143
+ "and adds no sampling, so it needs no re-run to confirm."
144
+ ),
145
+ }
146
+ return Result(analyzer=self.name, model=repr(model), cases=cases, findings=findings)
147
+
148
+ # ------------------------------------------------------------------
149
+ def _audit(self, case: "FailureCase", text: Any) -> dict[str, Any]:
150
+ equations = find_equations(text)
151
+ errors = [
152
+ (i, expr, stated, computed)
153
+ for i, (expr, stated, computed) in enumerate(equations)
154
+ if not _close(stated, computed, self.rel_tol)
155
+ ]
156
+ entry: dict[str, Any] = {
157
+ "sample_id": case.id,
158
+ "n_equations": len(equations),
159
+ "n_arith_errors": len(errors),
160
+ "arith_error_rate": (
161
+ round(len(errors) / len(equations), 4) if equations else 0.0
162
+ ),
163
+ }
164
+ if errors:
165
+ idx, expr, stated, computed = errors[0]
166
+ entry.update(
167
+ {
168
+ "first_error_idx": idx,
169
+ "first_error_expr": expr[:120],
170
+ "first_error_stated": stated,
171
+ "first_error_correct": computed,
172
+ # how deep into the written chain the first slip happened
173
+ "first_error_depth": round(idx / len(equations), 4),
174
+ }
175
+ )
176
+
177
+ predicted = self.answer_fn(text)
178
+ graded = (
179
+ bool(self.match_fn(predicted, case.expected))
180
+ if case.expected is not None
181
+ else None
182
+ )
183
+ if graded is None:
184
+ entry["error_class"] = UNKNOWN
185
+ entry["_note"] = "no gold answer: arithmetic checked, failure not classified"
186
+ return entry
187
+
188
+ entry["answer_correct"] = int(graded)
189
+ entry["predicted_answer"] = str(predicted)[:120]
190
+ if graded:
191
+ entry["error_class"] = LUCKY if errors else CLEAN
192
+ return entry
193
+ if not errors:
194
+ entry["error_class"] = CHAIN_BREAK if equations else UNKNOWN
195
+ return entry
196
+
197
+ explains = self._slip_explains_final(errors[0], predicted, case.expected)
198
+ entry["slip_explains_final"] = int(explains)
199
+ entry["error_class"] = SLIP if explains else COMPOUND
200
+ return entry
201
+
202
+ def _slip_explains_final(
203
+ self, error: tuple[int, str, float, float], predicted: Any, gold: Any
204
+ ) -> bool:
205
+ """Would repairing the FIRST wrong value have produced the gold answer?
206
+
207
+ Three propagation paths cover the realistic cases without re-running the
208
+ model: the repaired value IS the answer; the error carried through
209
+ additively; or it carried through multiplicatively.
210
+ """
211
+ _, _, stated, computed = error
212
+ gold_num = as_number(gold)
213
+ if gold_num is None:
214
+ return False
215
+ tol = self.rel_tol
216
+ if _close(computed, gold_num, tol):
217
+ return True
218
+ final_numbers = numbers_in(predicted)
219
+ if not final_numbers:
220
+ return False
221
+ final = final_numbers[-1]
222
+ if _close(final + (computed - stated), gold_num, tol):
223
+ return True
224
+ if stated != 0 and _close(final * (computed / stated), gold_num, tol):
225
+ return True
226
+ return False