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,2074 @@
1
+ """HF-local backend — the only InternalsHandle path.
2
+
3
+ Loads any spec via ``transformers`` and captures internals. Two-tier capture:
4
+
5
+ * **HF flags** (``output_attentions`` / ``output_hidden_states`` / logits) cover
6
+ attention, residual-stream hidden states and logits with NO module-path
7
+ surgery — this is the high-value common path.
8
+ * **hooks + runtime path discovery** (:mod:`evalrx.models._discover`) are
9
+ only needed beyond what flags give (activation patching, MoE routing,
10
+ gradients) — reserved for Stage 2.
11
+
12
+ Attention capture REQUIRES eager attention (sdpa/flash silently return ``None``),
13
+ so when the model declares ``eager_required_for_attn`` we force
14
+ ``attn_implementation="eager"``. ``torch`` / ``transformers`` are imported
15
+ lazily so this module imports on a torch-free install.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import math
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from evalrx.core.capability import Capability, CapabilityError
26
+ from evalrx.core.case import Inputs
27
+ from evalrx.core.model import Model, TokenLogprob, Trace
28
+ from evalrx.core.spec import AttnSemantics
29
+ from evalrx.core.tool import ChatTurn
30
+ from evalrx.models.backends.base import Backend, RuntimeConfig
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # capability -> HF forward flag
35
+ _CAPTURE_FLAGS = {
36
+ Capability.ATTENTION: "output_attentions",
37
+ Capability.HIDDEN_STATES: "output_hidden_states",
38
+ }
39
+
40
+
41
+ def _read_nested_attr(obj: Any, attr_path: "str | None", *, default: Any) -> Any:
42
+ """Walk a dotted attribute path on *obj*, returning *default* if any step is missing."""
43
+ if not attr_path:
44
+ return default
45
+ for part in attr_path.split("."):
46
+ obj = getattr(obj, part, None)
47
+ if obj is None:
48
+ return default
49
+ return obj
50
+
51
+
52
+ def _populate_vision_extras(
53
+ extras: dict,
54
+ input_ids: Any, # CPU torch.Tensor
55
+ proc_in: dict,
56
+ model_config: Any,
57
+ vision: Any, # VisionSpec — avoid circular import; duck-typed
58
+ ) -> None:
59
+ """Fill *extras* with image-token mask and spatial layout for analyzers.
60
+
61
+ Called from ``HFLocalModel._vlm_forward`` after the processor runs.
62
+ Writes:
63
+ ``image_token_mask`` — bool tensor (seq_len,) marking image-pad positions.
64
+ ``image_spatial_shape`` — (H, W) patch grid after spatial merge, for reshaping.
65
+ ``image_grid_thw`` — raw (T, H, W) tensor if grid_source=="grid_thw".
66
+ """
67
+ # A dotted attr (e.g. Omni's "thinker_config.image_token_id") needs the
68
+ # nested walker; a bare name behaves identically to getattr(), so no VLM
69
+ # spec's resolution changes.
70
+ image_token_id = _read_nested_attr(model_config, vision.image_token_id_attr, default=None)
71
+ if image_token_id is not None:
72
+ extras["image_token_mask"] = input_ids == image_token_id
73
+
74
+ merge = int(_read_nested_attr(model_config, vision.merge_size_attr, default=1) or 1)
75
+
76
+ if vision.grid_source == "grid_thw":
77
+ grid_t = proc_in.get("image_grid_thw")
78
+ if grid_t is not None:
79
+ grid = grid_t.cpu() if hasattr(grid_t, "cpu") else grid_t
80
+ extras["image_grid_thw"] = grid
81
+ _, h, w = int(grid[0, 0]), int(grid[0, 1]), int(grid[0, 2])
82
+ extras["image_spatial_shape"] = (h // merge, w // merge)
83
+ elif vision.grid_source == "grid_hw":
84
+ grid_t = proc_in.get("image_grid_hw")
85
+ if grid_t is not None:
86
+ grid = grid_t.cpu() if hasattr(grid_t, "cpu") else grid_t
87
+ extras["image_grid_hw"] = grid
88
+ h, w = int(grid[0, 0]), int(grid[0, 1])
89
+ extras["image_spatial_shape"] = (h // merge, w // merge)
90
+
91
+
92
+ def _populate_audio_extras(
93
+ extras: dict,
94
+ input_ids: Any, # CPU torch.Tensor
95
+ model_config: Any,
96
+ audio: Any, # AudioSpec — avoid circular import; duck-typed
97
+ ) -> None:
98
+ """Fill *extras* with the audio-token mask (the audio TokenTypeMap analog).
99
+
100
+ Symmetric to :func:`_populate_vision_extras`, minus the spatial-grid part —
101
+ audio placeholders are a flat run of one token per encoded frame-group, no
102
+ 2D reshape. Writes ``audio_token_mask`` — bool tensor (seq_len,) marking
103
+ audio-placeholder positions — which downstream paper methods (e.g. a
104
+ contrastive-decoding audio-reliance signal) read to isolate how much of the
105
+ decoder's attention lands on audio vs. text/image tokens.
106
+ """
107
+ audio_token_id = _read_nested_attr(model_config, audio.audio_token_id_attr, default=None)
108
+ if audio_token_id is not None:
109
+ extras["audio_token_mask"] = input_ids == audio_token_id
110
+
111
+
112
+ def _resolve_image(obj: Any) -> Any:
113
+ """Return a PIL image for *obj* (PIL passes through; str/Path is opened)."""
114
+ if hasattr(obj, "size") and hasattr(obj, "mode"): # already PIL-like
115
+ return obj
116
+ from PIL import Image
117
+
118
+ return Image.open(obj).convert("RGB")
119
+
120
+
121
+ # Sampling rate contract for ``Inputs.audio``: an ndarray is expected to already
122
+ # be mono float32 at this rate (matches the WhisperFeatureExtractor every
123
+ # audio-capable spec in this codebase uses). A path/URL is decoded to it here,
124
+ # so callers never have to think about resampling.
125
+ AUDIO_SAMPLE_RATE = 16000
126
+
127
+
128
+ def _resolve_audio(obj: Any) -> Any:
129
+ """Return a mono float32 waveform (numpy array) at :data:`AUDIO_SAMPLE_RATE`.
130
+
131
+ An already-decoded array passes through UNCHANGED — the caller is on the
132
+ hook for it being mono float32 @ 16 kHz; there is no signal here to detect
133
+ or fix a mismatched sample rate, so getting this wrong fails silently
134
+ downstream (the model just hears audio sped up/slowed down). A str/Path is
135
+ decoded via ``ffmpeg`` (already assumed present elsewhere in this codebase,
136
+ e.g. ``analysis/workbench.py``'s duration probing) rather than adding a new
137
+ audio-decoding dependency.
138
+ """
139
+ if hasattr(obj, "dtype") and hasattr(obj, "shape"): # already a numpy array
140
+ import numpy as np
141
+
142
+ arr = np.asarray(obj, dtype=np.float32)
143
+ if arr.ndim > 1:
144
+ # Silently flattening a (channels, samples) array would interleave
145
+ # channels into one stream -- audio at the wrong speed with channel
146
+ # aliasing, no error anywhere downstream. Mono is the documented
147
+ # contract; enforce it instead of guessing which axis is channels.
148
+ raise ValueError(
149
+ f"Inputs.audio must be a 1-D mono waveform, got shape {arr.shape}; "
150
+ "mix down to mono before passing it in"
151
+ )
152
+ return arr.reshape(-1)
153
+
154
+ import shutil
155
+ import subprocess
156
+
157
+ import numpy as np
158
+
159
+ if shutil.which("ffmpeg") is None:
160
+ raise RuntimeError(
161
+ "decoding audio from a path/URL requires the 'ffmpeg' binary on PATH; "
162
+ "install it, or pass Inputs.audio as an already-decoded mono float32 "
163
+ f"numpy array at {AUDIO_SAMPLE_RATE} Hz"
164
+ )
165
+ cmd = [
166
+ "ffmpeg", "-v", "error", "-i", str(obj),
167
+ "-f", "f32le", "-acodec", "pcm_f32le", "-ac", "1", "-ar", str(AUDIO_SAMPLE_RATE), "-",
168
+ ]
169
+ proc = subprocess.run(cmd, capture_output=True, check=False)
170
+ if proc.returncode != 0:
171
+ logger.warning("ffmpeg failed to decode audio %r: %s", obj, proc.stderr.decode(errors="replace"))
172
+ raise RuntimeError(
173
+ f"ffmpeg failed to decode audio {obj!r}: {proc.stderr.decode(errors='replace')}"
174
+ )
175
+ wav = np.frombuffer(proc.stdout, dtype="<f4").copy()
176
+ logger.debug("decoded audio %r via ffmpeg: %.1fs @ %dHz", obj, len(wav) / AUDIO_SAMPLE_RATE, AUDIO_SAMPLE_RATE)
177
+ return wav
178
+
179
+
180
+ def _check_audio_duration(audios: list, processor: Any, model_key: str) -> None:
181
+ """Raise before the processor silently truncates audio past its encoder window.
182
+
183
+ ``WhisperFeatureExtractor.chunk_length`` (seconds) differs per checkpoint —
184
+ Qwen2-Audio's is 30s, Qwen2.5-Omni's is 300s — and padding beyond it is
185
+ silently dropped rather than erroring (verified empirically: token count
186
+ and ``feature_attention_mask`` both cap at the window with no warning).
187
+ Read live from the processor, never baked, per this module's convention.
188
+ """
189
+ feature_extractor = getattr(processor, "feature_extractor", None)
190
+ chunk_length = getattr(feature_extractor, "chunk_length", None)
191
+ if chunk_length is None:
192
+ return # nothing to check this checkpoint's contract against
193
+ # ``audios`` are already resolved to AUDIO_SAMPLE_RATE by _resolve_audio, but
194
+ # the limit itself is read from the feature extractor's own rate rather than
195
+ # the module constant, so this stays correct if that ever diverges.
196
+ sampling_rate = getattr(feature_extractor, "sampling_rate", AUDIO_SAMPLE_RATE)
197
+ limit_samples = int(chunk_length) * int(sampling_rate)
198
+ for i, wav in enumerate(audios):
199
+ if len(wav) > limit_samples:
200
+ duration_sec = len(wav) / sampling_rate
201
+ logger.warning(
202
+ "%s: audio[%d] is %.1fs, longer than the %ds encoder window — refusing rather "
203
+ "than letting the processor silently truncate it",
204
+ model_key, i, duration_sec, chunk_length,
205
+ )
206
+ raise ValueError(
207
+ f"{model_key}: audio[{i}] is {duration_sec:.1f}s, longer than "
208
+ f"this checkpoint's {chunk_length}s encoder window — it would be silently "
209
+ "truncated rather than raising inside the processor. Chunk the audio yourself "
210
+ "before calling, or accept a documented context window in the caller."
211
+ )
212
+
213
+
214
+ def _new_tokens(out: Any, input_ids: Any) -> Any:
215
+ """The newly generated token ids from a ``generate()`` output sequence.
216
+
217
+ ``out`` is normally the prompt concatenated with the continuation, so the
218
+ new tokens start after ``len(input_ids)``. A model whose own
219
+ ``generate()`` converts the input to ``inputs_embeds`` internally
220
+ (Qwen3-Omni and other omni/audio architectures whose thinker merges
221
+ audio/vision embeddings before the LM) never hands ``input_ids`` back to
222
+ ``GenerationMixin`` — which then cannot prepend prompt token ids it was
223
+ never given, so it returns ONLY the new tokens (transformers itself warns
224
+ about this: "... without input_ids to generate ..."). Slicing at
225
+ ``len(input_ids)`` in that case cuts past the end of a sequence shorter
226
+ than the prompt and silently decodes to "" — seen live on
227
+ Qwen3-Omni-30B-A3B-Instruct/MMAU, every one of 128 cases, 2026-08-27.
228
+
229
+ A length check alone (``out.shape[0] > len(input_ids)``) is ambiguous at
230
+ the edges — a concatenated output with zero new tokens, or an
231
+ inputs_embeds-only output that happens to be exactly as long as the
232
+ prompt — so this compares the actual leading token ids: only a real
233
+ prefix match means ``out`` truly contains the prompt to cut off.
234
+ """
235
+ import torch
236
+
237
+ input_len = len(input_ids)
238
+ if out.shape[0] >= input_len and torch.equal(out[:input_len], torch.as_tensor(input_ids)):
239
+ return out[input_len:]
240
+ return out
241
+
242
+
243
+ def _collect_message_images(messages: list) -> list:
244
+ """Extract images from chat messages in appearance order.
245
+
246
+ Messages follow the transformers content-block convention: ``content`` is a
247
+ plain string OR a list of blocks, where an image block is
248
+ ``{"type": "image", "image": <PIL | path>}``. The block order across the
249
+ whole conversation must match the ``images=`` list handed to the processor.
250
+ """
251
+ images: list = []
252
+ for msg in messages:
253
+ content = msg.get("content")
254
+ if not isinstance(content, list):
255
+ continue
256
+ for block in content:
257
+ if isinstance(block, dict) and block.get("type") == "image":
258
+ img = block.get("image")
259
+ if img is not None:
260
+ images.append(_resolve_image(img))
261
+ return images
262
+
263
+
264
+ class HFLocalModel(Model):
265
+ """A locally-loaded HF model, constructed from a :class:`ModelSpec`."""
266
+
267
+ def __init__(self, spec, runtime: RuntimeConfig) -> None:
268
+ self.spec = spec
269
+ self.runtime = runtime
270
+ self._hf = None # (model, processor) — lazy
271
+ self._ifcd_editors: dict[str, Any] = {}
272
+ self._detect_engines: dict[str, Any] = {}
273
+ self._audio_text_engines: dict[tuple[str, str], Any] = {}
274
+ self._audio_api_specialist_engines: dict[str, Any] = {}
275
+ self._vision_api_specialist_engines: dict[str, Any] = {}
276
+ self._vision_specialist_engines: dict[str, Any] = {}
277
+ caps = {
278
+ Capability.GENERATE,
279
+ Capability.LOGITS,
280
+ Capability.LOGPROBS,
281
+ Capability.HIDDEN_STATES,
282
+ }
283
+ if spec.attn_semantics is not AttnSemantics.NONE:
284
+ caps.add(Capability.ATTENTION)
285
+ # TOOL_CALLS is a CONDITIONAL capability for local models: the backend
286
+ # provides the channel, but tool-calling only works if the model's chat
287
+ # template renders tools (declared per-model via spec.tool_calling).
288
+ if spec.tool_calling:
289
+ caps.add(Capability.TOOL_CALLS)
290
+ self.capabilities = frozenset(caps)
291
+ self.modalities = frozenset(spec.modalities) # text / image / audio / video, from the spec
292
+
293
+ def paper_method_fidelity(self, method: str) -> str:
294
+ """Declare whether a paper-method adapter is exact or architecture-adapted.
295
+
296
+ ``FixAgent`` admits architecture-native paper routes by default. An
297
+ experiment may explicitly opt into ``"adapted"`` methods, but reports
298
+ must retain that distinction rather than treating a same-formula port
299
+ to a different model architecture as a paper reproduction.
300
+ """
301
+ fidelity = self._paper_method_fidelity_impl(method)
302
+ logger.debug("%s: paper_method_fidelity(%r) -> %r", self.spec.key, method, fidelity)
303
+ return fidelity
304
+
305
+ def _paper_method_fidelity_impl(self, method: str) -> str:
306
+ if method == "vcd":
307
+ # The executor uses the released corruption, plausibility cutoff,
308
+ # and per-token sampler. Image-specific seeding keeps paired
309
+ # evaluation independent of iteration order.
310
+ return "per_item_seeded_sampler_specialization"
311
+ if method == "icd":
312
+ return (
313
+ "native_binary_specialization"
314
+ if self.spec.model_type == "instructblip"
315
+ else "adapted"
316
+ )
317
+ if method == "vicrop":
318
+ return (
319
+ "native_selector_specialization" if self.spec.model_type == "llava" else "adapted"
320
+ )
321
+ if method == "opera":
322
+ # This preserves OPERA's first-token over-trust penalty for POPE's
323
+ # binary task. Its beam rollback is not meaningful when exactly
324
+ # one output token is evaluated.
325
+ return (
326
+ "native_binary_specialization" if self.spec.model_type == "llava" else "unavailable"
327
+ )
328
+ if method == "ifcd":
329
+ checkpoint = self.runtime.engine_kwargs.get("ifcd_checkpoint")
330
+ if self.spec.model_type == "llava" and checkpoint and Path(str(checkpoint)).is_file():
331
+ # The public Vicuna TruthX artifact is compatible with
332
+ # LLaVA-1.5's decoder but is not the paper's MSCOCO-trained
333
+ # editor, and modern HF hooks after ``o_proj``. Never promote
334
+ # this to an exact IFCD reproduction.
335
+ return "adapted_truthx_artifact"
336
+ return "unavailable"
337
+ if method == "pai":
338
+ return (
339
+ "native_attention_cfg_specialization"
340
+ if self.spec.model_type == "llava"
341
+ else "unavailable"
342
+ )
343
+ if method == "tcd":
344
+ if self.spec.audio is None:
345
+ return "unavailable"
346
+ # Eq. 4 zips per-layer encoder stability with per-layer decoder
347
+ # audio-attention ratio index-for-index — faithful only when both
348
+ # towers have the same layer count. Qwen2-Audio-Instruct's
349
+ # Whisper-style encoder and Qwen2 decoder both have 32 layers
350
+ # (verified against the live config, not assumed); the paper's
351
+ # own hyperparameters (Appendix A) are anchored on this
352
+ # checkpoint. Every other registered audio spec has a depth
353
+ # mismatch (e.g. Qwen2.5-Omni: 32 encoder / 28 decoder), so
354
+ # generate_tcd truncates both to min(...) there instead of
355
+ # raising -- report that truncation as an adaptation.
356
+ return (
357
+ "native_layer_matched_stability"
358
+ if self.spec.key == "qwen2-audio-7b-instruct"
359
+ else "adapted_truncated_layer_stability"
360
+ )
361
+ if method == "aad":
362
+ # Unlike TCD, AAD's contrast (real audio vs. the same prompt with
363
+ # the waveform silenced) never reads architecture internals -- no
364
+ # layer counts, no attention weights, no audio-token span. The
365
+ # released repo's own claim is that this generalises across
366
+ # audio LALMs (Qwen2-Audio and SALMONN both evaluated); this
367
+ # codebase's registered audio specs are exactly the checkpoints
368
+ # that claim covers, so any of them is native, not adapted.
369
+ return "native_silence_contrast" if self.spec.audio is not None else "unavailable"
370
+ return "unavailable"
371
+
372
+ @classmethod
373
+ def from_loaded(
374
+ cls, model, tokenizer, spec=None, runtime: "RuntimeConfig | None" = None
375
+ ) -> "HFLocalModel":
376
+ """Wrap an ALREADY-LOADED HF model + tokenizer (the ``wrap()`` on-ramp).
377
+
378
+ Unlike the spec-driven path, no weights are fetched: we inject the live
379
+ ``(model, processor)`` directly and skip :meth:`load`. The spec is inferred
380
+ from the model when not given, and the attention capability is verified
381
+ against the live ``attn_implementation`` (eager is required, else the model
382
+ returns ``None`` attentions) — flipping it to eager when possible.
383
+ """
384
+ from evalrx.models.inference import infer_spec
385
+
386
+ spec = spec or infer_spec(model, tokenizer)
387
+ self = cls(spec, runtime or RuntimeConfig())
388
+ self._apply_family_shims(model, tokenizer)
389
+ self._hf = (model, tokenizer) # bypass lazy load(); a bare tokenizer acts as processor
390
+ if Capability.ATTENTION in self.capabilities:
391
+ self._ensure_eager_attention(model)
392
+ return self
393
+
394
+ #: Families whose repo code targets an older transformers generate() loop.
395
+ _GENERATE_SHIM_FAMILIES = frozenset({"nemotron_h", "nemotron_h_omni"})
396
+
397
+ def _apply_family_shims(self, model, processor) -> None:
398
+ """Per-family fixes so a loaded handle generates the way its authors intended.
399
+
400
+ NemotronH remote code (Nemotron 3 Nano, model card "tested on 4.48.3"):
401
+
402
+ * its ``prepare_inputs_for_generation`` builds the hybrid Mamba/attention
403
+ cache only when ``past_key_values is None``, but transformers >= 4.5x
404
+ pre-builds a ``DynamicCache`` for any class not named like a Mamba model
405
+ -> the repo code then warns "no NemotronHHybridDynamicCache provided"
406
+ and recomputes the whole sequence every step (1.9 tok/s on the 4B,
407
+ 2026-08-21). Telling generate() the model has no default dynamic cache
408
+ restores the repo's own cache path.
409
+ * its chat template closes every turn with ``<|im_end|>`` (the
410
+ tokenizer's eos_token, id 11) while ``generation_config.eos_token_id``
411
+ is ``</s>`` (2): generate() never stops and pads to the cap with
412
+ ``<|im_end|>\n`` pairs. Stopping on the tokenizer's EOS too fixes it.
413
+
414
+ Applied to the top-level model AND an embedded ``language_model`` (the
415
+ Omni wrapper generates through it). No-op for every other family.
416
+ """
417
+ if self.spec.family in self._GENERATE_SHIM_FAMILIES:
418
+ import types
419
+
420
+ tok = getattr(processor, "tokenizer", processor)
421
+ tok_eos = getattr(tok, "eos_token_id", None)
422
+ for target in (model, getattr(model, "language_model", None)):
423
+ if target is None:
424
+ continue
425
+ if hasattr(target, "_supports_default_dynamic_cache"):
426
+ target._supports_default_dynamic_cache = types.MethodType(lambda _self: False, target)
427
+ gen_cfg = getattr(target, "generation_config", None)
428
+ if gen_cfg is None or tok_eos is None:
429
+ continue
430
+ current = gen_cfg.eos_token_id
431
+ ids = list(current) if isinstance(current, (list, tuple)) else ([current] if current is not None else [])
432
+ if tok_eos not in ids:
433
+ gen_cfg.eos_token_id = ids + [int(tok_eos)]
434
+ logger.info("%s: generation stops on tokenizer eos %s as well (was %s)",
435
+ self.spec.key, tok_eos, current)
436
+ self._suppress_talker_audio(model)
437
+
438
+ def _suppress_talker_audio(self, model) -> None:
439
+ """Never synthesize speech through an omni model's talker for a text benchmark.
440
+
441
+ Qwen3-Omni-Instruct (config ``enable_audio_output=True`` on the
442
+ published checkpoint) sets ``self.has_talker = True`` at load, and its
443
+ own ``generate()`` defaults ``return_audio`` to ``self.has_talker``
444
+ when the caller doesn't pass it. Every call then runs the full
445
+ text-to-speech pipeline and returns ``(thinker_result.sequences,
446
+ talker_wav)`` -- a 2-tuple whose first element is ``[batch, seq_len]``
447
+ (batch first, not the single flat token sequence every other
448
+ ``model.generate()`` call site in this file assumes) -- instead of
449
+ just the thinker's text. Decoding that shape produced a batch-decode
450
+ (a Python list containing one string) that downstream code treated as
451
+ already-final text, so every generation surfaced as its own repr:
452
+ seen live on Qwen3-Omni-30B-A3B-Instruct/MMAU, every one of 128
453
+ cases, 2026-08-27 -- ``"['user\\n...\\nassistant\\nA']"``, not ``"A"``.
454
+
455
+ Nothing in this codebase's analysis reads a talker's audio output, so
456
+ request ``return_audio=False`` once here (read live off ``has_talker``
457
+ rather than a hard-coded family list, matching this module's
458
+ convention) instead of at all nine ``model.generate()`` call sites.
459
+ """
460
+ if not callable(getattr(model, "generate", None)):
461
+ return
462
+ original_generate = model.generate
463
+ if getattr(original_generate, "_evalrx_talker_suppressed", False):
464
+ return # already wrapped (re-entrant load/wrap paths)
465
+
466
+ def _generate_without_talker(*args, **kwargs):
467
+ if getattr(model, "has_talker", False):
468
+ kwargs.setdefault("return_audio", False)
469
+ return original_generate(*args, **kwargs)
470
+
471
+ _generate_without_talker._evalrx_talker_suppressed = True
472
+ model.generate = _generate_without_talker
473
+
474
+ @staticmethod
475
+ def _ensure_eager_attention(model) -> None:
476
+ """Best-effort switch to eager attention so ``output_attentions`` is populated.
477
+
478
+ sdpa / flash_attention_2 silently return ``None`` attentions. Newer
479
+ transformers expose ``set_attn_implementation``; otherwise we set the config
480
+ flag and warn that a reload may be required for it to take effect.
481
+ """
482
+ config = getattr(model, "config", None)
483
+ current = getattr(config, "_attn_implementation", None) if config is not None else None
484
+ if current == "eager":
485
+ return
486
+ if hasattr(model, "set_attn_implementation"):
487
+ try:
488
+ model.set_attn_implementation("eager")
489
+ return
490
+ except Exception: # pragma: no cover - falls through to the config flag
491
+ pass
492
+ if config is not None:
493
+ config._attn_implementation = "eager"
494
+ import warnings
495
+
496
+ msg = (
497
+ f"wrapped model used attn_implementation={current!r}; set it to 'eager' for "
498
+ "attention capture. If attentions come back empty, reload the model with "
499
+ "from_pretrained(..., attn_implementation='eager')."
500
+ )
501
+ logger.warning(msg)
502
+ warnings.warn(msg, stacklevel=2)
503
+
504
+ def unembed_weight(self):
505
+ """The lm_head / unembedding weight ``(vocab, dim)`` for logit-lens."""
506
+ from evalrx.models._discover import get_unembed
507
+
508
+ model, _ = self._loaded
509
+ head = get_unembed(model)
510
+ return getattr(head, "weight", None)
511
+
512
+ def final_norm(self):
513
+ """The final normalization module before the unembed (``None`` if not found).
514
+
515
+ RMSNorm-family models need ``lm_head(norm(h_i))``, not ``lm_head(h_i)``,
516
+ for faithful intermediate-layer readout (DeCo reference implementation).
517
+ """
518
+ from evalrx.models._discover import get_final_norm
519
+
520
+ model, _ = self._loaded
521
+ return get_final_norm(model)
522
+
523
+ # -- lazy load -----------------------------------------------------
524
+ def load(self) -> None:
525
+ import time
526
+
527
+ import torch
528
+ import transformers
529
+
530
+ start = time.monotonic()
531
+ logger.info(
532
+ "loading %s from %s (backend=hf_local, dtype=%s, device=%s)",
533
+ self.spec.key, self.spec.hf_repo, self.runtime.dtype, self.runtime.device,
534
+ )
535
+ auto_cls = getattr(transformers, self.spec.auto_class)
536
+ proc_cls = getattr(transformers, self.spec.processor_class, transformers.AutoProcessor)
537
+
538
+ attn_impl = self.runtime.attn_impl
539
+ if (
540
+ attn_impl is None
541
+ and self.spec.eager_required_for_attn
542
+ and Capability.ATTENTION in self.capabilities
543
+ ):
544
+ attn_impl = "eager" # sdpa/flash return None attentions
545
+
546
+ # Use `dtype` (the current transformers param; `torch_dtype` is deprecated).
547
+ kwargs: dict[str, Any] = dict(
548
+ dtype=getattr(torch, self.runtime.dtype),
549
+ trust_remote_code=self.spec.trust_remote_code,
550
+ )
551
+ if attn_impl:
552
+ kwargs["attn_implementation"] = attn_impl
553
+
554
+ device = self.runtime.device
555
+ if device in (None, "auto") or isinstance(device, dict):
556
+ # device_map path (multi-GPU / sharded) — needs accelerate
557
+ model = auto_cls.from_pretrained(
558
+ self.spec.hf_repo, device_map=device or "auto", **kwargs
559
+ )
560
+ else:
561
+ # explicit single device ("cuda" / "cuda:0" / "cpu") — no accelerate dependency
562
+ model = auto_cls.from_pretrained(self.spec.hf_repo, **kwargs).to(device)
563
+ model.eval()
564
+ processor = proc_cls.from_pretrained(
565
+ self.spec.hf_repo, trust_remote_code=self.spec.trust_remote_code
566
+ )
567
+
568
+ # Verify the declared TOOL_CALLS capability against the actual template.
569
+ if self.spec.tool_calling:
570
+ tok = getattr(processor, "tokenizer", processor)
571
+ template = getattr(tok, "chat_template", None) or ""
572
+ if "tools" not in template:
573
+ import warnings
574
+
575
+ msg = (
576
+ f"{self.spec.key!r}: spec.tool_calling=True but the chat template has no "
577
+ "'tools' handling — tool-calling may not render. Verify the checkpoint."
578
+ )
579
+ logger.warning(msg)
580
+ warnings.warn(msg)
581
+ self._apply_family_shims(model, processor)
582
+ self._hf = (model, processor)
583
+ logger.info(
584
+ "loaded %s in %.1fs (capabilities=%s)",
585
+ self.spec.key, time.monotonic() - start, sorted(c.value for c in self.capabilities),
586
+ )
587
+
588
+ @property
589
+ def _loaded(self):
590
+ if self._hf is None:
591
+ self.load()
592
+ return self._hf
593
+
594
+ @staticmethod
595
+ def _as_prompt(inputs: Any) -> str:
596
+ return inputs.prompt if isinstance(inputs, Inputs) else str(inputs)
597
+
598
+ def _render_text_prompt(self, tok: Any, prompt: str) -> str:
599
+ """The text a TEXT-ONLY spec feeds the tokenizer.
600
+
601
+ With ``runtime.apply_chat_template`` the prompt becomes one user turn
602
+ rendered through the chat template, carrying ``spec.chat_template_kwargs``
603
+ (``enable_thinking=False`` for the reasoning checkpoints). Without it —
604
+ the default — the prompt is tokenised verbatim: completion mode, where
605
+ an instruct/thinking model writes its own ``<think>`` block and never
606
+ sees the thinking switch (Qwen3.5-2B ran 8192 tokens that way on a
607
+ causal-judgement item, 2026-08-21).
608
+ """
609
+ if not self.runtime.apply_chat_template:
610
+ return prompt
611
+ if not getattr(tok, "chat_template", None) or not hasattr(tok, "apply_chat_template"):
612
+ logger.warning("%s: apply_chat_template requested but the tokenizer has no chat "
613
+ "template; tokenising the raw prompt", self.spec.key)
614
+ return prompt
615
+ return tok.apply_chat_template(
616
+ [{"role": "user", "content": prompt}],
617
+ add_generation_prompt=True,
618
+ tokenize=False,
619
+ **self.spec.chat_template_kwargs,
620
+ )
621
+
622
+ def _encode(self, prompt: str):
623
+ import torch # noqa: F401
624
+
625
+ model, processor = self._loaded
626
+ tok = getattr(processor, "tokenizer", processor)
627
+ text = self._render_text_prompt(tok, prompt)
628
+ if text is prompt:
629
+ enc = tok(text, return_tensors="pt")
630
+ else:
631
+ # a rendered template already carries BOS; a second one shifts every
632
+ # position the white-box analyzers read
633
+ enc = tok(text, return_tensors="pt", add_special_tokens=False)
634
+ device = next(model.parameters()).device
635
+ return {k: v.to(device) for k, v in enc.items()}
636
+
637
+ # -- interface -----------------------------------------------------
638
+ def generate(self, inputs: Any, **kwargs) -> str:
639
+ import torch
640
+
641
+ model, processor = self._loaded
642
+ tok = getattr(processor, "tokenizer", processor)
643
+ if self.spec.needs_multimodal_encode:
644
+ enc, _, _, _ = self._encode_vlm(inputs, model, processor)
645
+ else:
646
+ prompt = self._as_prompt(inputs)
647
+ enc = self._encode(prompt)
648
+ enc.pop("token_type_ids", None) # some VLM processors emit this; generate() rejects it
649
+ # "max_tokens" is the OpenAI-style name FixAgent's judge-proposed L2
650
+ # PipelineSpecs use (see fix_tools._safe_generation_kwargs and the
651
+ # _L2_PROMPT schema); transformers' generate() only recognises
652
+ # max_new_tokens and raises ValueError on an unrecognised kwarg
653
+ # rather than ignoring it, so every call in a judge-proposed
654
+ # pipeline silently failed (caught by run_pipeline's per-call
655
+ # try/except) and every case came back unscoreable. max_new_tokens
656
+ # wins if a caller passes both.
657
+ max_new = kwargs.pop("max_new_tokens", None)
658
+ if max_new is None:
659
+ max_new = kwargs.pop("max_tokens", self.runtime.max_new_tokens)
660
+ else:
661
+ kwargs.pop("max_tokens", None)
662
+ # PipelineSpec uses backend-neutral decoding controls. Translate the
663
+ # two controls that Hugging Face does not accept verbatim instead of
664
+ # letting run_pipeline swallow a ValueError and report 0 coverage.
665
+ temperature = kwargs.get("temperature")
666
+ if temperature is not None:
667
+ if float(temperature) <= 0.0:
668
+ kwargs.pop("temperature", None)
669
+ kwargs["do_sample"] = False
670
+ kwargs.pop("top_p", None)
671
+ kwargs.pop("top_k", None)
672
+ else:
673
+ kwargs.setdefault("do_sample", True)
674
+ stop = kwargs.pop("stop", None)
675
+ if stop:
676
+ kwargs["stop_strings"] = stop
677
+ kwargs["tokenizer"] = tok
678
+ with torch.no_grad():
679
+ out = model.generate(**enc, max_new_tokens=max_new, **kwargs)
680
+ new = _new_tokens(out[0], enc["input_ids"][0])
681
+ return tok.decode(new, skip_special_tokens=True)
682
+
683
+ def logprobs(
684
+ self, inputs: Any, max_new_tokens: int = 64, top_k: int = 5, **kwargs
685
+ ) -> list[TokenLogprob]:
686
+ """Per-output-token logprobs via greedy generate with output_scores."""
687
+ import torch
688
+
689
+ model, processor = self._loaded
690
+ tok = getattr(processor, "tokenizer", processor)
691
+ if self.spec.needs_multimodal_encode:
692
+ enc, _, _, _ = self._encode_vlm(inputs, model, processor)
693
+ else:
694
+ enc = self._encode(self._as_prompt(inputs))
695
+ enc.pop("token_type_ids", None)
696
+ with torch.no_grad():
697
+ out = model.generate(
698
+ **enc,
699
+ max_new_tokens=max_new_tokens,
700
+ do_sample=False,
701
+ output_scores=True,
702
+ return_dict_in_generate=True,
703
+ )
704
+ gen_ids = _new_tokens(out.sequences[0], enc["input_ids"][0]).tolist()
705
+ result: list[TokenLogprob] = []
706
+ for i, score in enumerate(out.scores):
707
+ lp = torch.log_softmax(score[0].float(), dim=-1)
708
+ tid = gen_ids[i]
709
+ topk = torch.topk(lp, min(top_k, lp.shape[-1]))
710
+ top = {tok.decode([int(j)]): float(v) for v, j in zip(topk.values, topk.indices)}
711
+ result.append(TokenLogprob(token=tok.decode([tid]), logprob=float(lp[tid]), top=top))
712
+ return result
713
+
714
+ def _vcd_encodings(
715
+ self,
716
+ inputs: Any,
717
+ *,
718
+ noise_step: int,
719
+ noise_seed: int,
720
+ ) -> tuple[Any, dict[str, Any]]:
721
+ """Build clean/noisy VCD inputs at the published tensor boundary.
722
+
723
+ The VCD release calls ``add_diffusion_noise`` *after* its image
724
+ processor: it perturbs the model's normalized image tensor, not an RGB
725
+ image that will subsequently be normalized again. Matching that
726
+ boundary is material; image-space noise has a different distribution.
727
+ A content-derived seed preserves paired-test reproducibility without
728
+ coupling one example's corruption to the iteration order of another.
729
+ """
730
+ import hashlib
731
+
732
+ import torch
733
+
734
+ model, processor = self._loaded
735
+ enc, _, _, _ = self._encode_vlm(inputs, model, processor)
736
+ enc.pop("token_type_ids", None)
737
+ pixels = enc.get("pixel_values")
738
+ if pixels is None:
739
+ raise ValueError(f"{self.spec.key}: VCD requires processor pixel_values")
740
+ step = max(0, min(999, int(noise_step)))
741
+ # This is vcd_utils/vcd_add_noise.py verbatim in numerical form.
742
+ betas = torch.sigmoid(torch.linspace(-6, 6, 1000, device=pixels.device))
743
+ betas = betas * (0.5e-2 - 1e-5) + 1e-5
744
+ alpha_bar = torch.cumprod(1 - betas, dim=0)[step].to(dtype=pixels.dtype)
745
+ digest = hashlib.sha256(pixels.detach().float().cpu().numpy().tobytes()).digest()
746
+ item_seed = (int(noise_seed) + int.from_bytes(digest[:8], "little")) % (2**63 - 1)
747
+ generator = torch.Generator(device=pixels.device).manual_seed(item_seed)
748
+ noise = torch.randn(
749
+ pixels.shape, device=pixels.device, dtype=pixels.dtype, generator=generator
750
+ )
751
+ noisy_enc = dict(enc)
752
+ noisy_enc["pixel_values"] = alpha_bar.sqrt() * pixels + (1 - alpha_bar).sqrt() * noise
753
+ return enc, noisy_enc
754
+
755
+ def _aad_encodings(self, inputs: Any) -> tuple[Any, dict[str, Any]]:
756
+ """Build real-audio/silent-audio AAD inputs at the published boundary.
757
+
758
+ AAD's release zeroes the raw WAVEFORM (``np.zeros_like(audio)``) and
759
+ re-runs it through the same feature extractor, not the post-extraction
760
+ feature tensor directly -- a zeroed waveform's log-mel features are
761
+ not literally zero, so matching that boundary (not skipping straight
762
+ to zeroed ``input_features``) is material, same reasoning as VCD's
763
+ noise-after-processor boundary above.
764
+ """
765
+ import numpy as np
766
+
767
+ from evalrx.core.case import Inputs
768
+
769
+ model, processor = self._loaded
770
+ audio = getattr(inputs, "audio", None)
771
+ if audio is None or isinstance(audio, (list, tuple)):
772
+ raise ValueError("AAD requires exactly one audio clip")
773
+ waveform = _resolve_audio(audio)
774
+ real_inputs = Inputs(
775
+ prompt=self._as_prompt(inputs),
776
+ image=getattr(inputs, "image", None),
777
+ audio=waveform,
778
+ video=getattr(inputs, "video", None),
779
+ )
780
+ silent_inputs = Inputs(
781
+ prompt=self._as_prompt(inputs),
782
+ image=getattr(inputs, "image", None),
783
+ audio=np.zeros_like(waveform),
784
+ video=getattr(inputs, "video", None),
785
+ )
786
+ enc, _ids, _tokens, _ttm = self._encode_vlm(real_inputs, model, processor)
787
+ enc.pop("token_type_ids", None)
788
+ silent_enc, _ids2, _tokens2, _ttm2 = self._encode_vlm(silent_inputs, model, processor)
789
+ silent_enc.pop("token_type_ids", None)
790
+ return enc, silent_enc
791
+
792
+ def generate_aad(self, inputs: Any, *, alpha: float = 0.5) -> str:
793
+ """Run AAD (Hsu et al. 2025, arXiv:2506.07233): contrast real-audio
794
+ decoding against the same prompt with the audio waveform silenced,
795
+ at every step. See :mod:`evalrx.models.paper_methods.aad`.
796
+ """
797
+ logger.debug("%s: generate_aad(alpha=%s)", self.spec.key, alpha)
798
+ if self.spec.audio is None:
799
+ raise ValueError(f"{self.spec.key}: AAD requires an audio-capable spec")
800
+ import torch
801
+ from transformers.generation.logits_process import LogitsProcessorList
802
+
803
+ from evalrx.models.paper_methods.aad import AADLogitsProcessor
804
+
805
+ model, processor = self._loaded
806
+ tok = getattr(processor, "tokenizer", processor)
807
+ enc, silent_enc = self._aad_encodings(inputs)
808
+ processor_list = LogitsProcessorList(
809
+ [AADLogitsProcessor(model, silent_enc, alpha=alpha)]
810
+ )
811
+ with torch.no_grad():
812
+ out = model.generate(
813
+ **enc,
814
+ max_new_tokens=self.runtime.max_new_tokens,
815
+ do_sample=False,
816
+ use_cache=True,
817
+ logits_processor=processor_list,
818
+ )
819
+ return tok.decode(_new_tokens(out[0], enc["input_ids"][0]), skip_special_tokens=True)
820
+
821
+ def generate_aad_baseline(self, inputs: Any) -> str:
822
+ """Greedy-decode the real-audio arm alone -- the paired-comparison
823
+ control AAD's own eligibility gate looks for (mirrors
824
+ generate_vcd_baseline/generate_tcd_baseline's role)."""
825
+ logger.debug("%s: generate_aad_baseline()", self.spec.key)
826
+ if self.spec.audio is None:
827
+ raise ValueError(f"{self.spec.key}: AAD requires an audio-capable spec")
828
+ import torch
829
+
830
+ model, processor = self._loaded
831
+ tok = getattr(processor, "tokenizer", processor)
832
+ enc, _silent_enc = self._aad_encodings(inputs)
833
+ with torch.no_grad():
834
+ out = model.generate(
835
+ **enc, max_new_tokens=self.runtime.max_new_tokens, do_sample=False, use_cache=True,
836
+ )
837
+ return tok.decode(_new_tokens(out[0], enc["input_ids"][0]), skip_special_tokens=True)
838
+
839
+ def _vcd_next_token_logits(
840
+ self,
841
+ inputs: Any,
842
+ *,
843
+ noise_step: int,
844
+ noise_seed: int,
845
+ ) -> tuple[Any, Any]:
846
+ """Return clean/noisy next-token logits for the legacy binary route."""
847
+ import torch
848
+
849
+ model, _ = self._loaded
850
+ enc, noisy_enc = self._vcd_encodings(
851
+ inputs, noise_step=noise_step, noise_seed=noise_seed
852
+ )
853
+ with torch.no_grad():
854
+ clean = model(**enc).logits[0, -1]
855
+ noisy = model(**noisy_enc).logits[0, -1]
856
+ return clean, noisy
857
+
858
+ def generate_vcd(
859
+ self,
860
+ inputs: Any,
861
+ *,
862
+ alpha: float = 0.5,
863
+ beta: float = 0.1,
864
+ noise_step: int = 500,
865
+ noise_seed: int = 55,
866
+ ) -> str:
867
+ """Run VCD's released contrastive formula through every output token.
868
+
869
+ The source sampler uses temperature-one multinomial sampling. Here the
870
+ diffusion noise is seeded per image content, rather than a global loop
871
+ RNG, so a frozen selection/confirmation split remains reproducible if
872
+ case order changes. That seed policy is recorded as a specialization.
873
+ """
874
+ logger.debug(
875
+ "%s: generate_vcd(alpha=%s, beta=%s, noise_step=%s, noise_seed=%s)",
876
+ self.spec.key, alpha, beta, noise_step, noise_seed,
877
+ )
878
+ if not self.spec.is_vlm:
879
+ raise ValueError("VCD visual contrast requires a VLM")
880
+ image = getattr(inputs, "image", None)
881
+ if image is None or isinstance(image, (list, tuple)):
882
+ raise ValueError("VCD requires exactly one image")
883
+ import hashlib
884
+
885
+ import torch
886
+ from transformers.generation.logits_process import LogitsProcessorList
887
+
888
+ model, processor = self._loaded
889
+ tok = getattr(processor, "tokenizer", processor)
890
+ clean_enc, noisy_enc = self._vcd_encodings(
891
+ inputs, noise_step=noise_step, noise_seed=noise_seed
892
+ )
893
+ from evalrx.models.paper_methods.vcd import VCDLogitsProcessor
894
+
895
+ # Match the release's temperature=1, top_p=1, no-top-k multinomial
896
+ # branch while preventing evaluation-order-dependent randomness.
897
+ fingerprint = hashlib.sha256(
898
+ clean_enc["pixel_values"].detach().float().cpu().numpy().tobytes()
899
+ ).digest()
900
+ item_seed = (int(noise_seed) + int.from_bytes(fingerprint[:8], "little")) % (2**63 - 1)
901
+ processor_list = LogitsProcessorList(
902
+ [VCDLogitsProcessor(model, noisy_enc, alpha=alpha, beta=beta)]
903
+ )
904
+ cuda_devices = [clean_enc["input_ids"].device.index] if clean_enc["input_ids"].is_cuda else []
905
+ with torch.random.fork_rng(devices=cuda_devices), torch.no_grad():
906
+ torch.manual_seed(item_seed)
907
+ if clean_enc["input_ids"].is_cuda:
908
+ torch.cuda.manual_seed(item_seed)
909
+ out = model.generate(
910
+ **clean_enc,
911
+ max_new_tokens=self.runtime.max_new_tokens,
912
+ do_sample=True,
913
+ temperature=1.0,
914
+ top_p=1.0,
915
+ top_k=0,
916
+ use_cache=True,
917
+ logits_processor=processor_list,
918
+ )
919
+ return tok.decode(_new_tokens(out[0], clean_enc["input_ids"][0]), skip_special_tokens=True)
920
+
921
+ def generate_vcd_baseline(self, inputs: Any, *, noise_seed: int = 55) -> str:
922
+ """Sample the clean VCD control with the candidate's per-image RNG.
923
+
924
+ VCD evaluates both arms with temperature-one multinomial sampling. A
925
+ greedy baseline paired with a sampled contrastive arm is not a valid
926
+ paper-method comparison, so the white-box runner calls this method
927
+ whenever it freezes the VCD candidate.
928
+ """
929
+ logger.debug("%s: generate_vcd_baseline(noise_seed=%s)", self.spec.key, noise_seed)
930
+ if not self.spec.is_vlm:
931
+ raise ValueError("VCD clean control requires a VLM")
932
+ image = getattr(inputs, "image", None)
933
+ if image is None or isinstance(image, (list, tuple)):
934
+ raise ValueError("VCD clean control requires one image")
935
+ import hashlib
936
+
937
+ import torch
938
+
939
+ model, processor = self._loaded
940
+ clean_enc, _ = self._vcd_encodings(inputs, noise_step=999, noise_seed=noise_seed)
941
+ fingerprint = hashlib.sha256(
942
+ clean_enc["pixel_values"].detach().float().cpu().numpy().tobytes()
943
+ ).digest()
944
+ item_seed = (int(noise_seed) + int.from_bytes(fingerprint[:8], "little")) % (2**63 - 1)
945
+ cuda_devices = [clean_enc["input_ids"].device.index] if clean_enc["input_ids"].is_cuda else []
946
+ with torch.random.fork_rng(devices=cuda_devices), torch.no_grad():
947
+ torch.manual_seed(item_seed)
948
+ if clean_enc["input_ids"].is_cuda:
949
+ torch.cuda.manual_seed(item_seed)
950
+ out = model.generate(
951
+ **clean_enc,
952
+ max_new_tokens=self.runtime.max_new_tokens,
953
+ do_sample=True,
954
+ temperature=1.0,
955
+ top_p=1.0,
956
+ top_k=0,
957
+ use_cache=True,
958
+ )
959
+ tok = getattr(processor, "tokenizer", processor)
960
+ return tok.decode(_new_tokens(out[0], clean_enc["input_ids"][0]), skip_special_tokens=True)
961
+
962
+ def generate_instruction_cd(
963
+ self,
964
+ inputs: Any,
965
+ *,
966
+ alpha: float = 1.0,
967
+ beta: float = 0.1,
968
+ qformer_mode: str = "normal",
969
+ disturbance: str = (
970
+ "You are a confused objects detector to provide a fuzzy overview "
971
+ "or impression of the image."
972
+ ),
973
+ ) -> str:
974
+ """ICD for a binary visual-grounding decision.
975
+
976
+ ICD (Wang et al., ACL 2024) contrasts a normal forward pass with a
977
+ *disturbance-instruction* pass. For InstructBLIP, the disturbance
978
+ replaces only ``qformer_input_ids`` while the decoder prompt remains
979
+ intact, matching the released ``normal.json`` route; ``qformer_mode``
980
+ can also run the released disturbed-question variant. Decoder-only
981
+ VLMs such as Qwen have no Q-Former, so their text-prefix fallback is
982
+ explicitly architecture-adapted.
983
+
984
+ As with :meth:`generate_vcd`, this intentionally supports only a
985
+ one-token Yes/No task. Applying a first-token shortcut to free-form
986
+ generation would not implement ICD's token-by-token sampler.
987
+ """
988
+ logger.debug(
989
+ "%s: generate_instruction_cd(alpha=%s, beta=%s, qformer_mode=%r)",
990
+ self.spec.key, alpha, beta, qformer_mode,
991
+ )
992
+ if not self.spec.is_vlm:
993
+ raise ValueError("instruction contrast requires a VLM")
994
+ image = getattr(inputs, "image", None)
995
+ if image is None or isinstance(image, (list, tuple)):
996
+ raise ValueError("instruction contrast requires one image and a binary answer task")
997
+ model, processor = self._loaded
998
+ tok = getattr(processor, "tokenizer", processor)
999
+
1000
+ def token_id(answer: str) -> int:
1001
+ for spelling in (" " + answer, answer):
1002
+ encoded = tok(spelling, add_special_tokens=False)["input_ids"]
1003
+ if len(encoded) == 1:
1004
+ return int(encoded[0])
1005
+ raise ValueError(
1006
+ f"{self.spec.key}: {answer!r} is not one token; ICD binary mode unavailable"
1007
+ )
1008
+
1009
+ if self.spec.model_type == "instructblip":
1010
+ import torch
1011
+
1012
+ if qformer_mode not in {"normal", "question"}:
1013
+ raise ValueError("qformer_mode must be 'normal' or 'question'")
1014
+ enc, _, _, _ = self._encode_vlm(inputs, model, processor)
1015
+ clean_enc = dict(enc)
1016
+ dirty_enc = dict(enc)
1017
+ disturbed_qformer_prompt = str(disturbance)
1018
+ if qformer_mode == "question":
1019
+ disturbed_qformer_prompt += self._as_prompt(inputs)
1020
+ qformer = processor.qformer_tokenizer(
1021
+ disturbed_qformer_prompt, return_tensors="pt", padding="longest", truncation=True
1022
+ ).to(next(model.parameters()).device)
1023
+ dirty_enc["qformer_input_ids"] = qformer["input_ids"]
1024
+ dirty_enc["qformer_attention_mask"] = qformer["attention_mask"]
1025
+ with torch.no_grad():
1026
+ clean = model(**clean_enc).logits[0, -1]
1027
+ disturbed = model(**dirty_enc).logits[0, -1]
1028
+ else:
1029
+ clean = self.forward(inputs, capture={Capability.LOGITS}).require(Capability.LOGITS)[-1]
1030
+ disturbed_inputs = Inputs(
1031
+ prompt=str(disturbance) + self._as_prompt(inputs), image=image
1032
+ )
1033
+ disturbed = self.forward(disturbed_inputs, capture={Capability.LOGITS}).require(
1034
+ Capability.LOGITS
1035
+ )[-1]
1036
+ ids = {answer: token_id(answer) for answer in ("Yes", "No")}
1037
+ clean_scores = {answer: float(clean[token].float()) for answer, token in ids.items()}
1038
+ disturbed_scores = {
1039
+ answer: float(disturbed[token].float()) for answer, token in ids.items()
1040
+ }
1041
+ cutoff = max(clean_scores.values()) + math.log(float(beta))
1042
+ scores = {
1043
+ answer: (1.0 + float(alpha)) * clean_scores[answer]
1044
+ - float(alpha) * disturbed_scores[answer]
1045
+ for answer in ids
1046
+ if clean_scores[answer] >= cutoff
1047
+ }
1048
+ return max(scores or clean_scores, key=(scores or clean_scores).get)
1049
+
1050
+ def generate_detector_grounded_presence(
1051
+ self,
1052
+ inputs: Any,
1053
+ *,
1054
+ baseline_answer: str,
1055
+ objects: list[str] | tuple[str, ...],
1056
+ detector_threshold: float = 0.25,
1057
+ detector_engine: Any | None = None,
1058
+ ) -> str:
1059
+ """Override a negative binary answer only with calibrated detector evidence.
1060
+
1061
+ The object allowlist and score threshold are explicit experiment payload,
1062
+ not inferred from the case label or metadata. Grounding DINO is queried
1063
+ only when the baseline is negative and the prompt names an allowlisted
1064
+ object, keeping the intervention narrow and deterministic.
1065
+ """
1066
+ import re
1067
+
1068
+ if re.search(r"\byes\b", str(baseline_answer), flags=re.IGNORECASE):
1069
+ return baseline_answer
1070
+ if not re.search(r"\bno\b", str(baseline_answer), flags=re.IGNORECASE):
1071
+ return baseline_answer
1072
+
1073
+ prompt = self._as_prompt(inputs)
1074
+ match = re.search(
1075
+ r"\b(?:is|are) there (?:an?\s+|any\s+)?(.+?)\s+in (?:the|this) image\b",
1076
+ prompt,
1077
+ flags=re.IGNORECASE,
1078
+ )
1079
+ if match is None:
1080
+ return baseline_answer
1081
+ query = re.sub(r"\s+", " ", match.group(1).strip().lower())
1082
+ allowed = {str(name).strip().lower() for name in objects}
1083
+ if query not in allowed:
1084
+ return baseline_answer
1085
+
1086
+ image = getattr(inputs, "image", None)
1087
+ if image is None or isinstance(image, (list, tuple)):
1088
+ return baseline_answer
1089
+ engine = detector_engine
1090
+ if engine is None:
1091
+ model, _ = self._loaded
1092
+ device = str(next(model.parameters()).device)
1093
+ engine = self._detect_engines.get(device)
1094
+ if engine is None:
1095
+ from evalrx.models.tools.perception import default_detect_engine
1096
+
1097
+ engine = default_detect_engine(
1098
+ device=device, threshold=0.01, text_threshold=0.01
1099
+ )
1100
+ self._detect_engines[device] = engine
1101
+ detections = engine(_resolve_image(image), query)
1102
+ score = max((float(item.get("score", 0.0)) for item in detections), default=0.0)
1103
+ return "Yes" if score >= float(detector_threshold) else baseline_answer
1104
+
1105
+ def generate_clap_grounded_presence(
1106
+ self,
1107
+ inputs: Any,
1108
+ *,
1109
+ baseline_answer: str,
1110
+ negative_threshold: float = -0.05,
1111
+ positive_threshold: float = 0.275,
1112
+ model_id: str = "laion/clap-htsat-unfused",
1113
+ similarity_engine: Any | None = None,
1114
+ ) -> str:
1115
+ """Gate binary sound-presence answers with calibrated CLAP evidence."""
1116
+ import re
1117
+
1118
+ baseline = str(baseline_answer)
1119
+ is_yes = re.search(r"\byes\b", baseline, flags=re.IGNORECASE) is not None
1120
+ is_no = re.search(r"\bno\b", baseline, flags=re.IGNORECASE) is not None
1121
+ if not (is_yes or is_no):
1122
+ return baseline_answer
1123
+ match = re.search(
1124
+ r"\bsound of (?:an?\s+)?(.+?)(?:\s+in (?:the|this) audio|\?)",
1125
+ self._as_prompt(inputs),
1126
+ flags=re.IGNORECASE,
1127
+ )
1128
+ if match is None:
1129
+ return baseline_answer
1130
+ query = re.sub(r"\s+", " ", match.group(1).strip().lower())
1131
+ audio = getattr(inputs, "audio", None)
1132
+ if audio is None or isinstance(audio, (list, tuple)):
1133
+ return baseline_answer
1134
+
1135
+ engine = similarity_engine
1136
+ if engine is None:
1137
+ model, _ = self._loaded
1138
+ device = str(next(model.parameters()).device)
1139
+ key = (str(model_id), device)
1140
+ engine = self._audio_text_engines.get(key)
1141
+ if engine is None:
1142
+ import torch
1143
+ from scipy.signal import resample_poly
1144
+ from transformers import ClapModel, ClapProcessor
1145
+
1146
+ processor = ClapProcessor.from_pretrained(model_id)
1147
+ clap = ClapModel.from_pretrained(model_id).to(device).eval()
1148
+
1149
+ def engine(waveform: Any, text: str) -> float:
1150
+ samples = resample_poly(waveform, 3, 1).astype("float32")
1151
+ encoded = processor(
1152
+ text=["the sound of " + text], audio=[samples],
1153
+ sampling_rate=48000, return_tensors="pt", padding=True,
1154
+ )
1155
+ encoded = {name: value.to(device) for name, value in encoded.items()}
1156
+ with torch.no_grad():
1157
+ audio_features = clap.get_audio_features(
1158
+ input_features=encoded["input_features"],
1159
+ is_longer=encoded.get("is_longer"),
1160
+ ).pooler_output
1161
+ text_features = clap.get_text_features(
1162
+ input_ids=encoded["input_ids"],
1163
+ attention_mask=encoded["attention_mask"],
1164
+ ).pooler_output
1165
+ return float(torch.nn.functional.cosine_similarity(
1166
+ audio_features, text_features
1167
+ )[0])
1168
+
1169
+ self._audio_text_engines[key] = engine
1170
+ score = float(engine(_resolve_audio(audio), query))
1171
+ if is_yes and score < float(negative_threshold):
1172
+ return "No"
1173
+ if is_no and score >= float(positive_threshold):
1174
+ return "Yes"
1175
+ return baseline_answer
1176
+
1177
+ def generate_audio_api_specialist(
1178
+ self,
1179
+ inputs: Any,
1180
+ *,
1181
+ baseline_answer: str,
1182
+ model_id: str = "gemini-3.7-flash",
1183
+ allowed_disagreements: list[str] | tuple[str, ...] | None = None,
1184
+ allowed_disagreements_by_route: dict[str, list[str]] | None = None,
1185
+ specialist_engine: Any | None = None,
1186
+ ) -> str:
1187
+ """Answer an audio question with a frozen external multimodal specialist."""
1188
+ import re
1189
+
1190
+ prompt = self._as_prompt(inputs)
1191
+ audio = getattr(inputs, "audio", None)
1192
+ if audio is None or isinstance(audio, (list, tuple)):
1193
+ return baseline_answer
1194
+ engine = specialist_engine or self._audio_api_specialist_engines.get(str(model_id))
1195
+ if engine is None:
1196
+ from evalrx.models.blackbox.gemini import GeminiModel
1197
+
1198
+ specialist = GeminiModel(model_id=str(model_id))
1199
+
1200
+ def engine(specialist_inputs: Inputs) -> str:
1201
+ turn = specialist.chat(
1202
+ [{
1203
+ "role": "user",
1204
+ "content": [
1205
+ {"type": "audio", "audio": specialist_inputs.audio},
1206
+ {"type": "text", "text": specialist_inputs.prompt},
1207
+ ],
1208
+ }]
1209
+ )
1210
+ return turn.text
1211
+
1212
+ self._audio_api_specialist_engines[str(model_id)] = engine
1213
+ answer = str(engine(Inputs(prompt=prompt, audio=audio)))
1214
+ route: str | None = None
1215
+ if allowed_disagreements_by_route is not None:
1216
+ question = prompt.split("\n", 1)[0]
1217
+ if re.search(
1218
+ r"\b(?:conversation|speakers?|utterance|words?|sentence|phoneme|"
1219
+ r"sarcas\w*|dialogue|people talking|emotional?\s+state|emotion of)\b",
1220
+ question,
1221
+ flags=re.IGNORECASE,
1222
+ ):
1223
+ route = "speech"
1224
+ elif re.search(
1225
+ r"\b(?:music|musical|melody|chord|guitar|drum|bass|synth|instrument|"
1226
+ r"rhythm|tempo|key signature|tonic|chant|singer|vocal|genre|harmonica|"
1227
+ r"percussive|song|vocals|time signature|audio quality|sound texture|"
1228
+ r"texture of the sound|e-guitar)\b",
1229
+ question,
1230
+ flags=re.IGNORECASE,
1231
+ ):
1232
+ route = "music"
1233
+ else:
1234
+ route = "sound"
1235
+ allowed_disagreements = allowed_disagreements_by_route.get(route, [])
1236
+ if allowed_disagreements is not None:
1237
+ baseline_letters = re.findall(r"\b([A-D])\b", str(baseline_answer).upper())
1238
+ specialist_letters = re.findall(r"\b([A-D])\b", answer.upper())
1239
+ if not baseline_letters or not specialist_letters:
1240
+ return baseline_answer
1241
+ pair = baseline_letters[0] + specialist_letters[0]
1242
+ if pair not in {str(item).upper() for item in allowed_disagreements}:
1243
+ return baseline_answer
1244
+ return answer
1245
+
1246
+ def generate_noncolor_spatial_specialist(
1247
+ self,
1248
+ inputs: Any,
1249
+ *,
1250
+ baseline_answer: str,
1251
+ model_id: str = "qwen2.5-vl-7b-instruct",
1252
+ specialist_engine: Any | None = None,
1253
+ ) -> str:
1254
+ """Route non-color spatial questions to a frozen vision specialist."""
1255
+ prompt = self._as_prompt(inputs)
1256
+ lowered = prompt.lower()
1257
+ if "shape" not in lowered and "color" in lowered:
1258
+ return baseline_answer
1259
+ image = getattr(inputs, "image", None)
1260
+ if image is None or isinstance(image, (list, tuple)):
1261
+ return baseline_answer
1262
+
1263
+ engine = specialist_engine or self._vision_specialist_engines.get(str(model_id))
1264
+ if engine is None:
1265
+ import gc
1266
+
1267
+ import torch
1268
+
1269
+ from evalrx.specs import get_spec
1270
+
1271
+ subject, _ = self._loaded
1272
+ device = str(next(subject.parameters()).device)
1273
+ subject.to("cpu")
1274
+ gc.collect()
1275
+ torch.cuda.empty_cache()
1276
+ specialist = HFLocalModel(
1277
+ get_spec(model_id),
1278
+ RuntimeConfig(
1279
+ device=device,
1280
+ dtype=self.runtime.dtype,
1281
+ attn_impl=self.runtime.attn_impl,
1282
+ max_new_tokens=self.runtime.max_new_tokens,
1283
+ apply_chat_template=True,
1284
+ ),
1285
+ )
1286
+ specialist.load()
1287
+ engine = specialist.generate
1288
+ self._vision_specialist_engines[str(model_id)] = engine
1289
+ return str(engine(Inputs(prompt=prompt, image=image)))
1290
+
1291
+ def generate_chart_vision_specialist(
1292
+ self,
1293
+ inputs: Any,
1294
+ *,
1295
+ baseline_answer: str,
1296
+ model_id: str = "qwen2.5-vl-7b-instruct",
1297
+ specialist_engine: Any | None = None,
1298
+ ) -> str:
1299
+ """Answer a chart question with a frozen, independently loaded VLM."""
1300
+ prompt = self._as_prompt(inputs)
1301
+ image = getattr(inputs, "image", None)
1302
+ if image is None or isinstance(image, (list, tuple)):
1303
+ return baseline_answer
1304
+ engine = specialist_engine or self._vision_specialist_engines.get(str(model_id))
1305
+ if engine is None:
1306
+ import gc
1307
+
1308
+ import torch
1309
+
1310
+ from evalrx.specs import get_spec
1311
+
1312
+ subject, _ = self._loaded
1313
+ device = str(next(subject.parameters()).device)
1314
+ subject.to("cpu")
1315
+ gc.collect()
1316
+ torch.cuda.empty_cache()
1317
+ specialist = HFLocalModel(
1318
+ get_spec(model_id),
1319
+ RuntimeConfig(
1320
+ device=device,
1321
+ dtype=self.runtime.dtype,
1322
+ attn_impl=self.runtime.attn_impl,
1323
+ max_new_tokens=self.runtime.max_new_tokens,
1324
+ apply_chat_template=True,
1325
+ ),
1326
+ )
1327
+ specialist.load()
1328
+ engine = specialist.generate
1329
+ self._vision_specialist_engines[str(model_id)] = engine
1330
+ return str(engine(Inputs(prompt=prompt, image=image)))
1331
+
1332
+ def generate_vision_api_specialist(
1333
+ self,
1334
+ inputs: Any,
1335
+ *,
1336
+ baseline_answer: str,
1337
+ model_id: str = "gemini-3.7-flash",
1338
+ specialist_engine: Any | None = None,
1339
+ ) -> str:
1340
+ """Answer an image question with a frozen external multimodal specialist."""
1341
+ import re
1342
+
1343
+ prompt = self._as_prompt(inputs)
1344
+ image = getattr(inputs, "image", None)
1345
+ if image is None or isinstance(image, (list, tuple)):
1346
+ return baseline_answer
1347
+ engine = specialist_engine or self._vision_api_specialist_engines.get(str(model_id))
1348
+ if engine is None:
1349
+ from evalrx.models.blackbox.gemini import GeminiModel
1350
+
1351
+ specialist = GeminiModel(model_id=str(model_id))
1352
+ engine = specialist.generate
1353
+ self._vision_api_specialist_engines[str(model_id)] = engine
1354
+ strict_prompt = prompt + (
1355
+ "\nReturn only the final short answer with no explanation, units, or formatting."
1356
+ )
1357
+ answer = str(engine(Inputs(prompt=strict_prompt, image=image))).strip()
1358
+ ratio = re.fullmatch(r"\s*([-+]?\d+(?:\.\d+)?)\s*:\s*([-+]?\d+(?:\.\d+)?)\s*", answer)
1359
+ if ratio is not None and float(ratio.group(2)) != 0:
1360
+ return str(float(ratio.group(1)) / float(ratio.group(2)))
1361
+ return answer
1362
+
1363
+ def generate_vicrop(self, inputs: Any, *, layer: int | float = 14) -> str:
1364
+ """Run the architecture-native LLaVA ViCrop paper executor."""
1365
+ logger.debug("%s: generate_vicrop(layer=%s)", self.spec.key, layer)
1366
+ if self.spec.model_type != "llava":
1367
+ raise ValueError(f"{self.spec.key}: ViCrop is only native on the LLaVA executor")
1368
+ from evalrx.models.paper_methods.vicrop import generate
1369
+
1370
+ return generate(self, inputs, layer=layer)
1371
+
1372
+ def generate_opera_binary(
1373
+ self,
1374
+ inputs: Any,
1375
+ *,
1376
+ num_attn_candidates: int = 5,
1377
+ penalty_weight: float = 1.0,
1378
+ ) -> str:
1379
+ """Run OPERA's first-token over-trust penalty for a binary VQA task.
1380
+
1381
+ OPERA scores each likely continuation by the image attention of the
1382
+ *candidate token* and subtracts ``-image_attention`` from its logit.
1383
+ This is its published early-response penalty verbatim. POPE evaluates
1384
+ a single Yes/No token, therefore the later multi-token rollback branch
1385
+ is intentionally out of scope and this method must stay labelled a
1386
+ binary specialization.
1387
+ """
1388
+ import torch
1389
+
1390
+ logger.debug(
1391
+ "%s: generate_opera_binary(num_attn_candidates=%s, penalty_weight=%s)",
1392
+ self.spec.key, num_attn_candidates, penalty_weight,
1393
+ )
1394
+ if self.spec.model_type != "llava":
1395
+ raise ValueError(f"{self.spec.key}: OPERA binary route is only native on LLaVA")
1396
+ if int(num_attn_candidates) < 1:
1397
+ raise ValueError("num_attn_candidates must be positive")
1398
+ model, processor = self._loaded
1399
+ enc, _, _, _ = self._encode_vlm(inputs, model, processor)
1400
+ enc.pop("token_type_ids", None)
1401
+ image_id = getattr(model.config, "image_token_index", None)
1402
+ if image_id is None:
1403
+ raise ValueError(f"{self.spec.key}: OPERA requires config.image_token_index")
1404
+ image_positions = (enc["input_ids"][0] == int(image_id)).nonzero().flatten()
1405
+ if image_positions.numel() == 0 or not bool(
1406
+ (image_positions[1:] == image_positions[:-1] + 1).all()
1407
+ ):
1408
+ raise ValueError(f"{self.spec.key}: OPERA requires one contiguous image-token span")
1409
+
1410
+ with torch.no_grad():
1411
+ prefill = model(**enc, return_dict=True, output_attentions=True, use_cache=False)
1412
+ if not getattr(prefill, "attentions", None):
1413
+ raise ValueError(f"{self.spec.key}: OPERA requires eager self-attention outputs")
1414
+ raw_logits = prefill.logits[:, -1, :]
1415
+ k = min(int(num_attn_candidates), int(raw_logits.shape[-1]))
1416
+ candidate_scores, candidate_tokens = torch.topk(raw_logits, k, dim=-1, largest=True, sorted=True)
1417
+ adjusted_scores = candidate_scores.clone()
1418
+ for candidate_index in range(k):
1419
+ candidate_enc = dict(enc)
1420
+ candidate_enc["input_ids"] = torch.cat(
1421
+ (enc["input_ids"], candidate_tokens[:, candidate_index : candidate_index + 1]), dim=1
1422
+ )
1423
+ if "attention_mask" in candidate_enc:
1424
+ candidate_enc["attention_mask"] = torch.cat(
1425
+ (enc["attention_mask"], torch.ones_like(enc["attention_mask"][:, :1])), dim=1
1426
+ )
1427
+ with torch.no_grad():
1428
+ candidate_output = model(
1429
+ **candidate_enc, return_dict=True, output_attentions=True, use_cache=False
1430
+ )
1431
+ attentions = getattr(candidate_output, "attentions", None)
1432
+ if not attentions:
1433
+ raise ValueError(f"{self.spec.key}: OPERA candidate forward returned no attentions")
1434
+ # Reference OPERA maximises heads then sums the candidate's image
1435
+ # attention. With one beam, selecting the adjusted top candidate
1436
+ # is identical to its first beam-search step.
1437
+ last_attention = attentions[-1].amax(dim=1)[:, -1, :]
1438
+ image_attention = last_attention[:, image_positions].sum(dim=-1)
1439
+ adjusted_scores[:, candidate_index] += float(penalty_weight) * image_attention
1440
+ selected = candidate_tokens.gather(1, adjusted_scores.argmax(dim=-1, keepdim=True)).squeeze(1)
1441
+ tok = getattr(processor, "tokenizer", processor)
1442
+ return tok.decode([int(selected[0])], skip_special_tokens=True)
1443
+
1444
+ def generate_ifcd(
1445
+ self,
1446
+ inputs: Any,
1447
+ *,
1448
+ alpha: float = 0.1,
1449
+ beta: float = 0.1,
1450
+ edit_strength: float = 0.5,
1451
+ top_layers: int = 15,
1452
+ max_new_tokens: int | None = None,
1453
+ ) -> str:
1454
+ """Run an explicitly adapted TruthX-backed IFCD decoder on LLaVA.
1455
+
1456
+ The checkpoint path is a required runtime artifact, rather than an
1457
+ implicit download: its provenance controls whether an experiment can
1458
+ compare itself with IFCD's MSCOCO-trained editor. This adapter uses
1459
+ modern HF output hooks, so even a matching artifact remains labelled
1460
+ adapted until its pre-``o_proj`` boundary is ported.
1461
+ """
1462
+ import torch
1463
+ from transformers.generation.logits_process import LogitsProcessorList
1464
+
1465
+ logger.debug(
1466
+ "%s: generate_ifcd(alpha=%s, beta=%s, edit_strength=%s, top_layers=%s)",
1467
+ self.spec.key, alpha, beta, edit_strength, top_layers,
1468
+ )
1469
+ if self.spec.model_type != "llava":
1470
+ raise ValueError(f"{self.spec.key}: IFCD is only wired for LLaVA's Vicuna decoder")
1471
+ checkpoint = self.runtime.engine_kwargs.get("ifcd_checkpoint")
1472
+ if not checkpoint or not Path(str(checkpoint)).is_file():
1473
+ raise ValueError("IFCD requires runtime.engine_kwargs['ifcd_checkpoint']")
1474
+ model, processor = self._loaded
1475
+ enc, _, _, _ = self._encode_vlm(inputs, model, processor)
1476
+ enc.pop("token_type_ids", None)
1477
+ key = f"{Path(str(checkpoint)).resolve()}:{int(top_layers)}"
1478
+ editor = self._ifcd_editors.get(key)
1479
+ if editor is None:
1480
+ from evalrx.models.paper_methods.ifcd import TruthXEditor
1481
+
1482
+ hidden_size = int(getattr(model.config.text_config, "hidden_size", 0) or model.config.hidden_size)
1483
+ editor = TruthXEditor(checkpoint, hidden_size=hidden_size, top_layers=top_layers)
1484
+ self._ifcd_editors[key] = editor
1485
+ from evalrx.models.paper_methods.ifcd import IFCDLogitsProcessor, truthx_editing
1486
+
1487
+ language_model = getattr(model, "language_model", model)
1488
+ editor.strength = float(edit_strength)
1489
+ processor_list = LogitsProcessorList(
1490
+ [
1491
+ IFCDLogitsProcessor(
1492
+ model,
1493
+ dict(enc),
1494
+ editor,
1495
+ alpha=alpha,
1496
+ beta=beta,
1497
+ edit_strength=edit_strength,
1498
+ )
1499
+ ]
1500
+ )
1501
+ with truthx_editing(language_model, editor), torch.no_grad():
1502
+ out = model.generate(
1503
+ **enc,
1504
+ max_new_tokens=max_new_tokens or self.runtime.max_new_tokens,
1505
+ do_sample=False,
1506
+ logits_processor=processor_list,
1507
+ )
1508
+ tok = getattr(processor, "tokenizer", processor)
1509
+ return tok.decode(_new_tokens(out[0], enc["input_ids"][0]), skip_special_tokens=True)
1510
+
1511
+ def generate_tcd(
1512
+ self,
1513
+ inputs: Any,
1514
+ *,
1515
+ max_new_tokens: int | None = None,
1516
+ hyperparams: "Any | None" = None,
1517
+ ) -> str:
1518
+ """Run Temporal Contrastive Decoding (Li et al. 2026, arXiv:2604.15383).
1519
+
1520
+ Cannot be built as a ``model.generate(logits_processor=[...])`` bolt-on
1521
+ the way VCD/IFCD/PAI are: a :class:`~transformers.LogitsProcessor` only
1522
+ ever sees ``(input_ids, scores)``, never the forward pass's attention
1523
+ weights, and TCD's gate (Eq. 8) needs the CURRENT step's decoder
1524
+ attention to audio tokens. So this runs its own greedy decode loop,
1525
+ holding two KV caches (original audio / Hann-blurred slow-path audio,
1526
+ Eq. 1) and calling the model directly each step -- exactly what VCD's
1527
+ processor already does for its single contrastive branch, just applied
1528
+ to both branches here, with ``output_attentions=True`` on the original
1529
+ branch to get the audio-attention ratio for free from the same forward
1530
+ (matches the paper's own reported ~1.00x decode-step overhead, Table 7:
1531
+ the attention weights are already computed internally, not an extra
1532
+ pass).
1533
+
1534
+ The per-example blur window and update scale (Eq. 5-6) are derived
1535
+ from a stability score (Eq. 2-4) computed ONCE before decoding starts,
1536
+ from (a) the audio encoder's own per-layer hidden-state trajectory on
1537
+ the *unblurred* audio, and (b) the decoder's per-layer attention to
1538
+ audio tokens during the prefill -- see
1539
+ :func:`evalrx.models.paper_methods.tcd.aggregate_stability` for
1540
+ why those two must have equal layer counts to be faithful, and
1541
+ ``paper_method_fidelity("tcd")`` for which registered specs qualify.
1542
+ """
1543
+ from evalrx.core.case import Inputs
1544
+ from evalrx.models.paper_methods import tcd
1545
+
1546
+ hp = hyperparams or tcd.TCDHyperparams()
1547
+ logger.debug(
1548
+ "%s: generate_tcd(l_attn=%s, tau=%s, gamma_gate=%s)",
1549
+ self.spec.key, hp.l_attn, hp.tau, hp.gamma_gate,
1550
+ )
1551
+ if self.spec.audio is None:
1552
+ raise ValueError(f"{self.spec.key}: TCD requires an audio-capable spec")
1553
+ audio = getattr(inputs, "audio", None) if isinstance(inputs, Inputs) else None
1554
+ if audio is None or isinstance(audio, (list, tuple)):
1555
+ raise ValueError("TCD requires exactly one audio clip")
1556
+
1557
+ import torch
1558
+
1559
+ model, processor = self._loaded
1560
+ tok = getattr(processor, "tokenizer", processor)
1561
+ waveform = _resolve_audio(audio)
1562
+ original_inputs = Inputs(
1563
+ prompt=self._as_prompt(inputs),
1564
+ image=getattr(inputs, "image", None),
1565
+ audio=waveform,
1566
+ video=getattr(inputs, "video", None),
1567
+ )
1568
+ enc, ids, _tokens, _ttm = self._encode_vlm(original_inputs, model, processor)
1569
+ enc.pop("token_type_ids", None)
1570
+ audio_token_id = _read_nested_attr(
1571
+ model.config, self.spec.audio.audio_token_id_attr, default=None
1572
+ )
1573
+ if audio_token_id is None:
1574
+ raise ValueError(f"{self.spec.key}: could not resolve the audio-token id from config")
1575
+ audio_mask = torch.tensor(ids, device=enc["input_ids"].device) == int(audio_token_id)
1576
+ if not bool(audio_mask.any()):
1577
+ raise ValueError(f"{self.spec.key}: no audio tokens found in the encoded prompt")
1578
+
1579
+ audio_tower = _read_nested_attr(model, self.spec.audio.audio_tower, default=None)
1580
+ if audio_tower is None:
1581
+ raise ValueError(
1582
+ f"{self.spec.key}: could not resolve audio_tower={self.spec.audio.audio_tower!r}"
1583
+ )
1584
+
1585
+ # -- Eq. 2-3: encoder-side per-layer stability, on the UNBLURRED audio --
1586
+ with torch.no_grad():
1587
+ audio_out = audio_tower(
1588
+ enc["input_features"], output_hidden_states=True, return_dict=True
1589
+ )
1590
+ # hidden_states[0] is pre-layer-0 embeddings, not a layer's own output.
1591
+ # Verified against Qwen2AudioEncoder.forward: entries 1..N-1 are the
1592
+ # RAW pre-pool output of layers 0..N-2 (seq_len == max_source_positions,
1593
+ # e.g. 1500), but the LAST entry (N) is layer N-1's output after
1594
+ # avg_pooler + layer_norm have already run -- half the seq_len and a
1595
+ # LayerNorm-pinned scale, not a like-for-like continuation of the rest.
1596
+ # layer_stability's M_l/F_l are computed independently per entry (no
1597
+ # cross-layer diffs), so this doesn't break Eq. 2-3, but it does mean
1598
+ # the LAST layer's S_l sits on a different footing before Eq. 4
1599
+ # softmax-weights it back in -- an approximation, not a bug, and one
1600
+ # this codebase's convention is to say plainly rather than round off.
1601
+ encoder_states = [h[0].float() for h in audio_out.hidden_states[1:]]
1602
+ layer_stability_scores = tcd.layer_stability(encoder_states, eps=hp.eps)
1603
+
1604
+ # -- prefill: original branch (also seeds its KV cache) --
1605
+ with torch.no_grad():
1606
+ prefill = model(**enc, use_cache=True, output_attentions=True, return_dict=True)
1607
+ if not getattr(prefill, "attentions", None):
1608
+ raise ValueError(f"{self.spec.key}: TCD requires eager self-attention outputs")
1609
+
1610
+ # -- Eq. 4: aggregate stability, weighted by the decoder's per-layer
1611
+ # audio-attention ratio from that same prefill. See
1612
+ # paper_method_fidelity("tcd") for the equal-layer-count requirement
1613
+ # this truncation is standing in for on a mismatched-depth spec.
1614
+ # Indexed straight off prefill.attentions (still bf16) one layer at a
1615
+ # time -- audio_attention_ratio does its own float() per call, so this
1616
+ # never holds more than one layer's fp32 copy at once. A real MMAU clip
1617
+ # is ~780 tokens; materializing all 32 layers' (heads, 780, 780) fp32
1618
+ # attentions up front, as an earlier version of this method did, would
1619
+ # be several GB of copies purely for a scalar-per-layer reduction. --
1620
+ n_layers = min(layer_stability_scores.shape[0], len(prefill.attentions))
1621
+ layer_ratio = torch.stack(
1622
+ [
1623
+ tcd.audio_attention_ratio(prefill.attentions[i][0], audio_mask)
1624
+ for i in range(n_layers)
1625
+ ]
1626
+ )
1627
+ stability = tcd.aggregate_stability(
1628
+ layer_stability_scores[:n_layers], layer_ratio, temperature=hp.tau
1629
+ )
1630
+ window_ms, lam = tcd.adaptive_blur_params(stability, hp)
1631
+ logger.debug(
1632
+ "%s: generate_tcd stability=%.4f window_ms=%.2f lam=%.4f",
1633
+ self.spec.key, stability, window_ms, lam,
1634
+ )
1635
+
1636
+ # -- Eq. 1: blur + re-encode (once, up front -- not per decode step) --
1637
+ blurred_waveform = tcd.hann_blur_waveform(waveform, AUDIO_SAMPLE_RATE, window_ms)
1638
+ blurred_inputs = Inputs(
1639
+ prompt=original_inputs.prompt, image=original_inputs.image,
1640
+ audio=blurred_waveform, video=original_inputs.video,
1641
+ )
1642
+ blurred_enc, _, _, _ = self._encode_vlm(blurred_inputs, model, processor)
1643
+ blurred_enc.pop("token_type_ids", None)
1644
+ with torch.no_grad():
1645
+ blurred_prefill = model(**blurred_enc, use_cache=True, return_dict=True)
1646
+
1647
+ eos_ids = set()
1648
+ if getattr(tok, "eos_token_id", None) is not None:
1649
+ eos_ids.add(int(tok.eos_token_id))
1650
+ gen_eos = getattr(getattr(model, "generation_config", None), "eos_token_id", None)
1651
+ if isinstance(gen_eos, (list, tuple, set)):
1652
+ eos_ids.update(int(e) for e in gen_eos)
1653
+ elif gen_eos is not None:
1654
+ eos_ids.add(int(gen_eos))
1655
+
1656
+ max_new = int(max_new_tokens or self.runtime.max_new_tokens)
1657
+ original_kv = prefill.past_key_values
1658
+ blurred_kv = blurred_prefill.past_key_values
1659
+ z = prefill.logits[0, -1].float()
1660
+ z_tilde = blurred_prefill.logits[0, -1].float()
1661
+ last_layers_attn = [a[0].float() for a in prefill.attentions[-hp.l_attn :]]
1662
+ mask = audio_mask.clone()
1663
+ generated: list[int] = []
1664
+
1665
+ with torch.no_grad():
1666
+ for _ in range(max_new):
1667
+ r_t = float(
1668
+ torch.stack(
1669
+ [tcd.audio_attention_ratio(a, mask) for a in last_layers_attn]
1670
+ ).mean()
1671
+ )
1672
+ entropy_hat = tcd.topk_renormalized_entropy(z, hp.k_ent)
1673
+ gate_value = tcd.reliance_gate(r_t, entropy_hat, hp)
1674
+ fused = tcd.fuse_logits(z, z_tilde, lam=lam, gate_value=gate_value, hp=hp)
1675
+ next_id = int(torch.argmax(fused))
1676
+ if next_id in eos_ids:
1677
+ break
1678
+ generated.append(next_id)
1679
+
1680
+ next_input = torch.tensor([[next_id]], device=z.device)
1681
+ out = model(
1682
+ input_ids=next_input, past_key_values=original_kv,
1683
+ use_cache=True, output_attentions=True, return_dict=True,
1684
+ )
1685
+ out_tilde = model(
1686
+ input_ids=next_input, past_key_values=blurred_kv,
1687
+ use_cache=True, return_dict=True,
1688
+ )
1689
+ original_kv = out.past_key_values
1690
+ blurred_kv = out_tilde.past_key_values
1691
+ z = out.logits[0, -1].float()
1692
+ z_tilde = out_tilde.logits[0, -1].float()
1693
+ last_layers_attn = [a[0].float() for a in out.attentions[-hp.l_attn :]]
1694
+ mask = torch.cat([mask, torch.zeros(1, dtype=torch.bool, device=mask.device)])
1695
+
1696
+ return tok.decode(generated, skip_special_tokens=True)
1697
+
1698
+ def generate_tcd_baseline(self, inputs: Any, *, max_new_tokens: int | None = None) -> str:
1699
+ """Greedy baseline paired with :meth:`generate_tcd`.
1700
+
1701
+ ``generate_tcd`` is greedy by construction (Eq. 9's fused logits feed
1702
+ a plain argmax, no sampler). ``Qwen2-Audio-7B-Instruct``'s own
1703
+ ``generation_config`` defaults to ``do_sample=True`` (temperature 0.7,
1704
+ top_p 0.5, top_k 20) -- calling the bare :meth:`generate` for a
1705
+ baseline would inherit that and silently pair a sampled arm against a
1706
+ greedy one, exactly the mismatch ``generate_vcd_baseline`` exists to
1707
+ avoid for VCD (see its docstring). The paper's own baseline is
1708
+ greedy too (Table 7's "Baseline (Greedy)"; Section 4.1).
1709
+ """
1710
+ logger.debug("%s: generate_tcd_baseline()", self.spec.key)
1711
+ if self.spec.audio is None:
1712
+ raise ValueError(f"{self.spec.key}: TCD requires an audio-capable spec")
1713
+ return self.generate(inputs, max_new_tokens=max_new_tokens, do_sample=False)
1714
+
1715
+ def generate_vicrop_consensus(
1716
+ self,
1717
+ inputs: Any,
1718
+ *,
1719
+ baseline_answer: str,
1720
+ layer: int | float = 14,
1721
+ ) -> str:
1722
+ """Use ViCrop only when independent crop and fused-view answers agree.
1723
+
1724
+ This is a deployment safety guard, not part of the source ViCrop
1725
+ method. It prevents a single misleading attention crop from replacing
1726
+ an otherwise stable baseline answer.
1727
+ """
1728
+ import re
1729
+
1730
+ logger.debug("%s: generate_vicrop_consensus(layer=%s)", self.spec.key, layer)
1731
+ if self.spec.model_type != "llava":
1732
+ raise ValueError(f"{self.spec.key}: ViCrop is only native on the LLaVA executor")
1733
+ from evalrx.models.paper_methods.vicrop import prepare_views
1734
+
1735
+ image, crop, fused_prompt = prepare_views(self, inputs, layer=layer)
1736
+ fused = self.generate(Inputs(fused_prompt, [image, crop]))
1737
+ crop_only_prompt = (
1738
+ "The image is a task-relative crop selected from a larger scene. "
1739
+ "Answer only from visible evidence in this crop.\n\n"
1740
+ + self._as_prompt(inputs)
1741
+ )
1742
+ crop_only = self.generate(Inputs(crop_only_prompt, crop))
1743
+
1744
+ def decision(text: str) -> str:
1745
+ lowered = str(text).lower()
1746
+ yes_no = re.search(r"\b(yes|no)\b", lowered)
1747
+ if yes_no:
1748
+ return yes_no.group(1)
1749
+ choices = re.findall(r"\b([a-d])\b", lowered)
1750
+ if choices:
1751
+ return choices[-1]
1752
+ return re.sub(r"\W+", "", lowered)
1753
+
1754
+ return fused if decision(fused) and decision(fused) == decision(crop_only) else baseline_answer
1755
+
1756
+ def generate_pai(
1757
+ self,
1758
+ inputs: Any,
1759
+ *,
1760
+ alpha: float = 0.2,
1761
+ guidance_scale: float = 2.0,
1762
+ start_layer: int = 2,
1763
+ end_layer: int = 32,
1764
+ max_new_tokens: int | None = None,
1765
+ ) -> str:
1766
+ """Run PAI's attention and classifier-free-guidance route on LLaVA.
1767
+
1768
+ The attention boost and the image-free classifier-free-guidance cache
1769
+ follow the released PAI decoding path. It remains an architecture
1770
+ specialization because the source uses its pinned LLaVA stack.
1771
+ """
1772
+ import torch
1773
+
1774
+ logger.debug(
1775
+ "%s: generate_pai(alpha=%s, guidance_scale=%s, start_layer=%s, end_layer=%s)",
1776
+ self.spec.key, alpha, guidance_scale, start_layer, end_layer,
1777
+ )
1778
+ if self.spec.model_type != "llava":
1779
+ raise ValueError(f"{self.spec.key}: PAI is only native on the LLaVA executor")
1780
+ model, processor = self._loaded
1781
+ enc, _, _, _ = self._encode_vlm(inputs, model, processor)
1782
+ enc.pop("token_type_ids", None)
1783
+ image_id = getattr(model.config, "image_token_index", None)
1784
+ if image_id is None:
1785
+ raise ValueError(f"{self.spec.key}: PAI requires config.image_token_index")
1786
+ positions = (enc["input_ids"][0] == int(image_id)).nonzero().flatten()
1787
+ if positions.numel() == 0 or not bool((positions[1:] == positions[:-1] + 1).all()):
1788
+ raise ValueError(f"{self.spec.key}: PAI requires one contiguous image-token span")
1789
+ from transformers.generation.logits_process import LogitsProcessorList
1790
+
1791
+ from evalrx.models.paper_methods.pai import (
1792
+ PAICFGLogitsProcessor,
1793
+ image_attention_boost,
1794
+ )
1795
+
1796
+ # PAI's reference code patches the LLaMA language model, whereas HF
1797
+ # LLaVA wraps it in ``LlavaForConditionalGeneration``.
1798
+ language_model = getattr(model, "language_model", model)
1799
+ unconditional_ids = torch.cat(
1800
+ (enc["input_ids"][:, : positions[0]], enc["input_ids"][:, positions[-1] + 1 :]),
1801
+ dim=1,
1802
+ )
1803
+ cfg = PAICFGLogitsProcessor(
1804
+ model,
1805
+ unconditional_ids,
1806
+ guidance_scale=guidance_scale,
1807
+ start_layer=start_layer,
1808
+ end_layer=end_layer,
1809
+ )
1810
+
1811
+ with image_attention_boost(
1812
+ language_model,
1813
+ image_start=int(positions[0]),
1814
+ image_end=int(positions[-1]) + 1,
1815
+ alpha=alpha,
1816
+ start_layer=start_layer,
1817
+ end_layer=end_layer,
1818
+ ):
1819
+ with torch.no_grad():
1820
+ out = model.generate(
1821
+ **enc,
1822
+ max_new_tokens=max_new_tokens or self.runtime.max_new_tokens,
1823
+ do_sample=False,
1824
+ logits_processor=LogitsProcessorList([cfg]),
1825
+ )
1826
+ tok = getattr(processor, "tokenizer", processor)
1827
+ return tok.decode(_new_tokens(out[0], enc["input_ids"][0]), skip_special_tokens=True)
1828
+
1829
+ def chat(self, messages: list, tools=None) -> ChatTurn:
1830
+ """Tool-aware turn via the model's chat template.
1831
+
1832
+ transformers' ``apply_chat_template(tools=...)`` accepts OpenAI-format tool
1833
+ schemas and renders them into the prompt; the model emits the call as text
1834
+ which the (Qwen/Hermes) codec parses out — so ``raw_tool_calls`` is None.
1835
+
1836
+ Multimodal: messages may carry transformers-style content blocks
1837
+ (``{"type": "image", "image": ...}``); on a VLM the images are routed
1838
+ through the processor so each block's placeholder tokens line up with
1839
+ its pixels — this is what lets the agent loop feed tool-returned crops
1840
+ back to the model.
1841
+ """
1842
+ import torch
1843
+
1844
+ if Capability.TOOL_CALLS not in self.capabilities:
1845
+ raise CapabilityError(
1846
+ analyzer="chat", model=repr(self), missing={Capability.TOOL_CALLS}
1847
+ )
1848
+ model, processor = self._loaded
1849
+ tok = getattr(processor, "tokenizer", processor)
1850
+ images = _collect_message_images(messages) if self.spec.is_vlm else []
1851
+ if images:
1852
+ try:
1853
+ text = processor.apply_chat_template(
1854
+ messages,
1855
+ tools=tools,
1856
+ add_generation_prompt=True,
1857
+ tokenize=False,
1858
+ **self.spec.chat_template_kwargs,
1859
+ )
1860
+ except (
1861
+ TypeError
1862
+ ): # older processors don't take tools= — same jinja template lives on the tokenizer
1863
+ text = tok.apply_chat_template(
1864
+ messages,
1865
+ tools=tools,
1866
+ add_generation_prompt=True,
1867
+ tokenize=False,
1868
+ **self.spec.chat_template_kwargs,
1869
+ )
1870
+ enc = processor(text=[text], images=images, return_tensors="pt")
1871
+ enc.pop("token_type_ids", None) # some VLM processors emit this; generate() rejects it
1872
+ enc = enc.to(next(model.parameters()).device)
1873
+ else:
1874
+ text = tok.apply_chat_template(
1875
+ messages,
1876
+ tools=tools,
1877
+ add_generation_prompt=True,
1878
+ tokenize=False,
1879
+ **self.spec.chat_template_kwargs, # e.g. {"enable_thinking": False} for Qwen3
1880
+ )
1881
+ enc = tok(text, return_tensors="pt").to(next(model.parameters()).device)
1882
+ with torch.no_grad():
1883
+ out = model.generate(**enc, max_new_tokens=self.runtime.max_new_tokens)
1884
+ n_in = enc["input_ids"].shape[1]
1885
+ new_ids = _new_tokens(out[0], enc["input_ids"][0])
1886
+ gen = tok.decode(new_ids, skip_special_tokens=True)
1887
+ usage = {"prompt_tokens": int(n_in), "completion_tokens": int(new_ids.shape[0])}
1888
+ return ChatTurn(text=gen, raw_tool_calls=None, usage=usage)
1889
+
1890
+ def _encode_vlm(self, inputs, model, processor):
1891
+ """Encode an (image/video/audio, text) input and build its TokenTypeMap.
1892
+
1893
+ Despite the name (kept to avoid touching every call site), this is the
1894
+ general multimodal encode path: it also carries ``inputs.audio`` for
1895
+ omni/audio-only specs. Builds the TokenTypeMap from the processor output
1896
+ BEFORE moving to device, using the live config (image_token_id,
1897
+ vision_config.spatial_merge_size) + the spec's VisionSpec — so token ids /
1898
+ merge sizes are never hard-coded. ``self.spec.vision is None`` (an
1899
+ audio-only spec) skips the TokenTypeMap entirely — it is VLM-specific.
1900
+
1901
+ ``inputs.video`` (list of PIL frames) takes priority over ``inputs.image``:
1902
+ each frame gets its own ``{"type": "image"}`` content slot so the processor
1903
+ inserts a separate image-token block per frame, enabling multi-frame /
1904
+ temporal inputs without a native video tower.
1905
+
1906
+ Audio is independent of the image/video branch: any spec with
1907
+ ``self.spec.audio is not None`` that receives ``inputs.audio`` gets an
1908
+ ``{"type": "audio"}`` content block plus the decoded waveform(s) passed
1909
+ through the processor's ``audio=`` kwarg at :data:`AUDIO_SAMPLE_RATE`.
1910
+ """
1911
+ from evalrx.core.tokentype import build_token_type_map
1912
+
1913
+ tok = getattr(processor, "tokenizer", processor)
1914
+ prompt = self._as_prompt(inputs)
1915
+ image = getattr(inputs, "image", None) if isinstance(inputs, Inputs) else None
1916
+ video = getattr(inputs, "video", None) if isinstance(inputs, Inputs) else None
1917
+ audio = getattr(inputs, "audio", None) if isinstance(inputs, Inputs) else None
1918
+
1919
+ if video is not None:
1920
+ # Multi-frame path: one <image> placeholder per frame, frames passed in order.
1921
+ content = [{"type": "image"} for _ in video] + [{"type": "text", "text": prompt}]
1922
+ images = list(video)
1923
+ elif image is not None:
1924
+ images = list(image) if isinstance(image, (list, tuple)) else [image]
1925
+ content = [{"type": "image"} for _ in images] + [{"type": "text", "text": prompt}]
1926
+ else:
1927
+ content = [{"type": "text", "text": prompt}]
1928
+ images = []
1929
+
1930
+ audios: list = []
1931
+ if self.spec.audio is not None and audio is not None:
1932
+ raw_audios = list(audio) if isinstance(audio, (list, tuple)) else [audio]
1933
+ audios = [_resolve_audio(a) for a in raw_audios]
1934
+ # Audio blocks precede the text block, matching the image/video
1935
+ # convention above; order must match the ``audio=`` list below.
1936
+ content = [{"type": "audio"} for _ in audios] + content
1937
+
1938
+ if self.spec.model_type == "instructblip":
1939
+ if len(images) != 1:
1940
+ raise ValueError("InstructBLIP supports exactly one image per request")
1941
+ # InstructBLIP has no chat template or decoder image placeholder:
1942
+ # its processor returns vision pixels plus an independent Q-Former
1943
+ # text sequence. The latter is what ICD perturbs. Vicuna-based
1944
+ # InstructBLIP expects an explicit answer turn; without it the
1945
+ # checkpoint often terminates immediately after echoing the prompt.
1946
+ qformer_prompt = prompt
1947
+ decoder_prompt = f"Question: {prompt} Answer:"
1948
+ enc = processor(images=images[0], text=decoder_prompt, return_tensors="pt")
1949
+ # The published ICD implementation gives the Q-Former the raw
1950
+ # question while the Vicuna decoder receives its answer template.
1951
+ # Override the processor's coupled default to retain that split.
1952
+ qformer = processor.qformer_tokenizer(
1953
+ qformer_prompt, return_tensors="pt", padding="longest", truncation=True
1954
+ )
1955
+ enc["qformer_input_ids"] = qformer["input_ids"]
1956
+ enc["qformer_attention_mask"] = qformer["attention_mask"]
1957
+ else:
1958
+ text = processor.apply_chat_template(
1959
+ [{"role": "user", "content": content}],
1960
+ add_generation_prompt=True,
1961
+ tokenize=False,
1962
+ **self.spec.chat_template_kwargs,
1963
+ )
1964
+ proc_kwargs = {"text": [text], "return_tensors": "pt"}
1965
+ if images:
1966
+ proc_kwargs["images"] = images
1967
+ if audios:
1968
+ _check_audio_duration(audios, processor, self.spec.key)
1969
+ proc_kwargs["audio"] = audios
1970
+ proc_kwargs["sampling_rate"] = AUDIO_SAMPLE_RATE
1971
+ enc = processor(**proc_kwargs)
1972
+ ttm = (
1973
+ build_token_type_map(enc["input_ids"], enc, model.config, self.spec.vision)
1974
+ if self.spec.vision is not None
1975
+ else None
1976
+ )
1977
+ enc = enc.to(next(model.parameters()).device)
1978
+ ids = enc["input_ids"][0].tolist()
1979
+ tokens = [tok.decode([i]) for i in ids]
1980
+ return enc, ids, tokens, ttm
1981
+
1982
+ def forward(self, inputs: Any, capture: set[Capability], spec=None) -> Trace:
1983
+ import torch
1984
+
1985
+ model, processor = self._loaded
1986
+ if self.spec.needs_multimodal_encode:
1987
+ enc, token_ids, tokens, ttm = self._encode_vlm(inputs, model, processor)
1988
+ else:
1989
+ tok = getattr(processor, "tokenizer", processor)
1990
+ enc = self._encode(self._as_prompt(inputs))
1991
+ token_ids = enc["input_ids"][0].tolist()
1992
+ tokens = [tok.decode([t]) for t in token_ids]
1993
+ ttm = None
1994
+
1995
+ extras: dict = {"attn_semantics": self.spec.attn_semantics.value}
1996
+ if self.spec.vision is not None:
1997
+ image = getattr(inputs, "image", None) if isinstance(inputs, Inputs) else None
1998
+ video = getattr(inputs, "video", None) if isinstance(inputs, Inputs) else None
1999
+ if image is not None or video is not None:
2000
+ _populate_vision_extras(
2001
+ extras, torch.tensor(token_ids), enc, model.config, self.spec.vision
2002
+ )
2003
+ if self.spec.audio is not None:
2004
+ audio = getattr(inputs, "audio", None) if isinstance(inputs, Inputs) else None
2005
+ if audio is not None:
2006
+ _populate_audio_extras(extras, torch.tensor(token_ids), model.config, self.spec.audio)
2007
+
2008
+ flags = {flag: True for cap, flag in _CAPTURE_FLAGS.items() if cap in capture}
2009
+ enc.pop("token_type_ids", None) # some VLM processors emit this; forward() rejects it
2010
+ with torch.no_grad():
2011
+ outputs = model(**enc, **flags)
2012
+
2013
+ layers = spec.layers if spec is not None else None
2014
+ to_cpu = spec.to_cpu if spec is not None else True
2015
+
2016
+ def _maybe_subset(seq):
2017
+ return [seq[i] for i in layers] if layers is not None else list(seq)
2018
+
2019
+ def _move(t):
2020
+ return t.cpu() if to_cpu else t
2021
+
2022
+ provided: set[Capability] = set()
2023
+ attentions = hidden_states = logits = None
2024
+ if Capability.ATTENTION in capture:
2025
+ if getattr(outputs, "attentions", None) is None:
2026
+ raise RuntimeError(
2027
+ f"{self!r}: ATTENTION was requested but the model returned no attentions. "
2028
+ "Load it with attn_implementation='eager' (sdpa/flash silently return None)."
2029
+ )
2030
+ attentions = [_move(a.squeeze(0)) for a in _maybe_subset(outputs.attentions)]
2031
+ provided.add(Capability.ATTENTION)
2032
+ if (
2033
+ Capability.HIDDEN_STATES in capture
2034
+ and getattr(outputs, "hidden_states", None) is not None
2035
+ ):
2036
+ hidden_states = [_move(h.squeeze(0)) for h in _maybe_subset(outputs.hidden_states)]
2037
+ provided.add(Capability.HIDDEN_STATES)
2038
+ if Capability.LOGITS in capture:
2039
+ logits = _move(outputs.logits.squeeze(0))
2040
+ provided.add(Capability.LOGITS)
2041
+
2042
+ return Trace(
2043
+ tokens=tokens,
2044
+ token_ids=token_ids,
2045
+ provided=provided,
2046
+ attentions=attentions,
2047
+ hidden_states=hidden_states,
2048
+ logits=logits,
2049
+ token_type_map=ttm,
2050
+ extras=extras,
2051
+ )
2052
+
2053
+ def __repr__(self) -> str:
2054
+ status = "loaded" if self._hf else "lazy"
2055
+ return f"HFLocalModel(key={self.spec.key!r}, {status})"
2056
+
2057
+
2058
+ class HFLocalBackend(Backend):
2059
+ kind = "hf_local"
2060
+ # Superset the backend CAN provide; the actual per-model set is computed in
2061
+ # HFLocalModel.__init__ (e.g. TOOL_CALLS only when the spec's template supports it).
2062
+ capabilities = frozenset(
2063
+ {
2064
+ Capability.GENERATE,
2065
+ Capability.TOOL_CALLS,
2066
+ Capability.LOGITS,
2067
+ Capability.LOGPROBS,
2068
+ Capability.HIDDEN_STATES,
2069
+ Capability.ATTENTION,
2070
+ }
2071
+ )
2072
+
2073
+ def build(self, spec, runtime: RuntimeConfig) -> HFLocalModel:
2074
+ return HFLocalModel(spec, runtime)