interp-engine 1.3.6__tar.gz → 1.4.0__tar.gz

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 (176) hide show
  1. {interp_engine-1.3.6 → interp_engine-1.4.0}/PKG-INFO +8 -1
  2. {interp_engine-1.3.6 → interp_engine-1.4.0}/README.md +7 -0
  3. {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/ARCHITECTURE_QUIRKS.md +71 -19
  4. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/arch.py +79 -5
  5. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/capture.py +8 -1
  6. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/facts.py +168 -3
  7. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/hooks.py +29 -0
  8. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/model.py +53 -1
  9. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/steer.py +15 -1
  10. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/_hooks.py +48 -1
  11. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/_tree.py +112 -4
  12. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/requests.py +44 -1
  13. {interp_engine-1.3.6 → interp_engine-1.4.0}/pyproject.toml +1 -1
  14. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/synthetic_families.py +51 -0
  15. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_moe.py +145 -7
  16. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_per_layer_attn_dims.py +145 -1
  17. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_new_points.py +79 -1
  18. {interp_engine-1.3.6 → interp_engine-1.4.0}/.gitignore +0 -0
  19. {interp_engine-1.3.6 → interp_engine-1.4.0}/LICENSE +0 -0
  20. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/README.md +0 -0
  21. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/__init__.py +0 -0
  22. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/bench_spec.py +0 -0
  23. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/cells.py +0 -0
  24. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/probe.py +0 -0
  25. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/probe_lens_stream.py +0 -0
  26. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/publish.py +0 -0
  27. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/report_bench.py +0 -0
  28. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/deepseek-v4-flash-0731__eager.json +0 -0
  29. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/deepseek-v4-flash-0731__vllm-cudagraph.json +0 -0
  30. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark-cudagraph.json +0 -0
  31. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark.json +0 -0
  32. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/deepseek-v4-flash-0731__vllm-static.json +0 -0
  33. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/deepseek-v4-flash-0731__vllm.json +0 -0
  34. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/gemma-2-2b__eager.json +0 -0
  35. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/gemma-2-2b__vllm-cudagraph.json +0 -0
  36. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/gemma-2-2b__vllm-static.json +0 -0
  37. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/gemma-2-2b__vllm.json +0 -0
  38. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/llama-3.1-8b__eager.json +0 -0
  39. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/llama-3.1-8b__vllm-cudagraph.json +0 -0
  40. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/llama-3.1-8b__vllm-static.json +0 -0
  41. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/llama-3.1-8b__vllm.json +0 -0
  42. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3-4b__eager.json +0 -0
  43. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3-4b__vllm-cudagraph.json +0 -0
  44. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3-4b__vllm-static.json +0 -0
  45. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3-4b__vllm.json +0 -0
  46. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3.8-27b__eager.json +0 -0
  47. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3.8-27b__vllm-cudagraph.json +0 -0
  48. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3.8-27b__vllm-static.json +0 -0
  49. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3.8-27b__vllm.json +0 -0
  50. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results-latest.md +0 -0
  51. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/run_all.sh +0 -0
  52. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/run_bench.py +0 -0
  53. {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/workloads.py +0 -0
  54. {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/AGENT_INTEGRATION.md +0 -0
  55. {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/COMPATIBILITY.md +0 -0
  56. {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/ENGINE_HOOK_MAPPINGS.md +0 -0
  57. {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/GRADIENTS.md +0 -0
  58. {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/INTERNALS.md +0 -0
  59. {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/PERFORMANCE.md +0 -0
  60. {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/PORTING.md +0 -0
  61. {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/README.md +0 -0
  62. {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/SUPPORTED_POINTS.md +0 -0
  63. {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/USAGE.md +0 -0
  64. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/__init__.py +0 -0
  65. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/_loop.py +0 -0
  66. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/address.py +0 -0
  67. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/attn_config.py +0 -0
  68. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/attn_scores.py +0 -0
  69. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/autograd_support.py +0 -0
  70. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/chat_compose.py +0 -0
  71. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/chat_conventions.py +0 -0
  72. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/chat_formatters.py +0 -0
  73. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/cuda_preflight.py +0 -0
  74. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/dispatch.py +0 -0
  75. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/lens.py +0 -0
  76. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/load.py +0 -0
  77. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/mappers.py +0 -0
  78. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/moe_routing.py +0 -0
  79. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/notebook_stdout.py +0 -0
  80. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/points.py +0 -0
  81. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/protocol.py +0 -0
  82. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/residual_basis.py +0 -0
  83. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/select.py +0 -0
  84. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/steer_specs.py +0 -0
  85. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/sync.py +0 -0
  86. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/tokenize.py +0 -0
  87. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_backend.py +0 -0
  88. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/__init__.py +0 -0
  89. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/_demux.py +0 -0
  90. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/_payload.py +0 -0
  91. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/attn.py +0 -0
  92. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/capture.py +0 -0
  93. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/graphs.py +0 -0
  94. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/lens/__init__.py +0 -0
  95. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/lens/intervene.py +0 -0
  96. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/lens/readout.py +0 -0
  97. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/lens/unembed.py +0 -0
  98. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/mhc.py +0 -0
  99. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/native.py +0 -0
  100. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/static.py +0 -0
  101. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/steering.py +0 -0
  102. {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_plugin.py +0 -0
  103. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/conftest.py +0 -0
  104. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/harness.py +0 -0
  105. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/model_expectations.yaml +0 -0
  106. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_address.py +0 -0
  107. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_attn_config_tripwire.py +0 -0
  108. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_attn_probs_indexing.py +0 -0
  109. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_attn_scores.py +0 -0
  110. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_attn_z_gqa.py +0 -0
  111. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_autograd_support.py +0 -0
  112. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_bench_workloads.py +0 -0
  113. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_capability_refusals.py +0 -0
  114. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_capture_addressing.py +0 -0
  115. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_chat_compose.py +0 -0
  116. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_chat_formatters.py +0 -0
  117. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_chat_templates.py +0 -0
  118. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_core.py +0 -0
  119. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_cuda_preflight.py +0 -0
  120. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_doc_code_fences.py +0 -0
  121. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_eager_autograd.py +0 -0
  122. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_facts.py +0 -0
  123. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_family_points.py +0 -0
  124. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_gated_attn_out.py +0 -0
  125. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_head_contributions.py +0 -0
  126. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_hook_call_conventions.py +0 -0
  127. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_layer_kinds.py +0 -0
  128. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_load.py +0 -0
  129. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_logit_transform.py +0 -0
  130. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_mappers.py +0 -0
  131. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_mlp_internals.py +0 -0
  132. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_model_expectations.py +0 -0
  133. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_multimodal_arch.py +0 -0
  134. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_new_models_gpu.py +0 -0
  135. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_no_chat_template.py +0 -0
  136. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_normalized_hook.py +0 -0
  137. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_notebook_stdout.py +0 -0
  138. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_packaging.py +0 -0
  139. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_parity_gpt2.py +0 -0
  140. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_points_registry.py +0 -0
  141. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_protocol.py +0 -0
  142. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_published_benchmarks.py +0 -0
  143. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_qk_norm.py +0 -0
  144. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_qkv_layout.py +0 -0
  145. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_reasoning_spans.py +0 -0
  146. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_release.py +0 -0
  147. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_resid_mid.py +0 -0
  148. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_residual_basis.py +0 -0
  149. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_sandwich_norms.py +0 -0
  150. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_select.py +0 -0
  151. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_sliding_window_attn.py +0 -0
  152. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_small_models_gpu.py +0 -0
  153. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_static_dsv4_gpu.py +0 -0
  154. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_static_parity_gpu.py +0 -0
  155. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_static_set.py +0 -0
  156. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_static_warmup.py +0 -0
  157. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_steer_context.py +0 -0
  158. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_steer_math_parity.py +0 -0
  159. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_sync_loop.py +0 -0
  160. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_sync_parity.py +0 -0
  161. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_unified_free_functions.py +0 -0
  162. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_unresolved_families.py +0 -0
  163. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_capture_gpu.py +0 -0
  164. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_capture_scales.py +0 -0
  165. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_engine_loop.py +0 -0
  166. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_graph_path.py +0 -0
  167. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_graphs_on_gpu.py +0 -0
  168. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_hook_availability.py +0 -0
  169. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_hyper_connections.py +0 -0
  170. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_kv_isolation.py +0 -0
  171. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_only_families.py +0 -0
  172. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_plugin.py +0 -0
  173. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_wire_grammar.py +0 -0
  174. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vocabulary_boundary.py +0 -0
  175. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_worker_lens_capture_readout.py +0 -0
  176. {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_worker_lens_readout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: interp-engine
3
- Version: 1.3.6
3
+ Version: 1.4.0
4
4
  Summary: A fast, standardized, and easy to use interpretability engine.
5
5
  Project-URL: Homepage, https://github.com/decoderesearch/interp-engine
6
6
  Project-URL: Repository, https://github.com/decoderesearch/interp-engine
@@ -139,6 +139,13 @@ We verify correctness in two main ways:
139
139
  1. A test suite that checks results across several models - what each check is designed to catch is in [INTERNALS.md](docs/INTERNALS.md#correctness).
140
140
  2. A full `validator` comparison engine that checks most hook points across 50+ models, at early, middle and late layers - fully reproducible, with detailed results saved in the git repo at [`validator/`](validator/).
141
141
 
142
+ ## Known Issues
143
+
144
+ - **Gemma 4 requires transformers 5.14.1** because 5.15 moved `head_dim` into `per_layer_config` and vLLM's config read dies before a weight loads ([vllm#51744](https://github.com/vllm-project/vllm/issues/51744)).
145
+ - **Gemma 4 26B refuses `mlp_out`**, because `layer.mlp` there is one branch of a two-branch feed-forward; `mlp_out_post` is the residual contribution and is served ([ARCHITECTURE_QUIRKS.md](docs/ARCHITECTURE_QUIRKS.md#moe-tap-the-block-not-the-experts)).
146
+ - **DeepSeek-V2 on transformers older than 5.15.0** captures a wrong attention temperature — the engine warns at load, and upgrading is the fix ([COMPATIBILITY.md](docs/COMPATIBILITY.md)).
147
+ - **MXFP4 checkpoints (gpt-oss) need `interp-engine[quant]`**, which the `[vllm]` extra does not include; without it transformers dequantizes them to bf16 at roughly 3x the weights, which can turn a model that fits into one that does not.
148
+
142
149
  ## Why use an Interpretability Engine instead of building from scratch?
143
150
 
144
151
  1. **Speed**: Get performance without sacrificing correctness.
@@ -108,6 +108,13 @@ We verify correctness in two main ways:
108
108
  1. A test suite that checks results across several models - what each check is designed to catch is in [INTERNALS.md](docs/INTERNALS.md#correctness).
109
109
  2. A full `validator` comparison engine that checks most hook points across 50+ models, at early, middle and late layers - fully reproducible, with detailed results saved in the git repo at [`validator/`](validator/).
110
110
 
111
+ ## Known Issues
112
+
113
+ - **Gemma 4 requires transformers 5.14.1** because 5.15 moved `head_dim` into `per_layer_config` and vLLM's config read dies before a weight loads ([vllm#51744](https://github.com/vllm-project/vllm/issues/51744)).
114
+ - **Gemma 4 26B refuses `mlp_out`**, because `layer.mlp` there is one branch of a two-branch feed-forward; `mlp_out_post` is the residual contribution and is served ([ARCHITECTURE_QUIRKS.md](docs/ARCHITECTURE_QUIRKS.md#moe-tap-the-block-not-the-experts)).
115
+ - **DeepSeek-V2 on transformers older than 5.15.0** captures a wrong attention temperature — the engine warns at load, and upgrading is the fix ([COMPATIBILITY.md](docs/COMPATIBILITY.md)).
116
+ - **MXFP4 checkpoints (gpt-oss) need `interp-engine[quant]`**, which the `[vllm]` extra does not include; without it transformers dequantizes them to bf16 at roughly 3x the weights, which can turn a model that fits into one that does not.
117
+
111
118
  ## Why use an Interpretability Engine instead of building from scratch?
112
119
 
113
120
  1. **Speed**: Get performance without sacrificing correctness.
@@ -291,12 +291,49 @@ way for a layer to have no `v_proj`, and it does not overlap with KV sharing: th
291
291
  against a key of `rope(k_norm(k_proj(h)))`, so the two are the same projection read through
292
292
  different norms, not the same tensor.
293
293
 
294
- **What the engine does with that today is wrong in the quiet direction.** `_first_attr` treats a
295
- present-but-`None` attribute as absent, so `ArchSpec.v_proj` returns `None` on those layers — which
296
- is the signal it uses for "fused QKV, the caller must split" — on a family whose `fused_qkv` is
297
- false. The layer needs a third answer: `value` is capturable, from `k_proj` and the `v_norm` after
298
- it, and refusing or mis-addressing it is not the same as it not being there. Nothing exercises this
299
- yet because the 31B has been swept without the value points.
294
+ **The third answer that layer needed is the norm, and it applies to the whole family.**
295
+ `ArchSpec.v_proj` still returns `None` there, which is the signal it uses for "fused QKV, the caller
296
+ must split", on a family whose `fused_qkv` is false — so `value` no longer asks it first.
297
+ `ArchSpec.value_module` does, and it returns the `v_norm` whenever the family has one
298
+ (`facts.ATTN_VALUE_NORM_ATTRS`). Two things that buys, and the first would apply even without
299
+ `attention_k_eq_v`:
300
+
301
+ - `Gemma4TextAttention.forward` runs `value_states = self.v_norm(value_states)` on **every** layer
302
+ that projects its own KV, so the projection's output is a norm short of the tensor the attention
303
+ pattern multiplies. `value` was off by that norm on the sliding layers too, which do have a
304
+ `v_proj`.
305
+ - vLLM has no value projection to compare against at all — its `Gemma4Attention` splits one fused
306
+ `qkv_proj`, with the K weights loaded into the V slot on the `k_eq_v` layers — but it runs the same
307
+ `v_norm` on the V slice. So this is the only boundary at which the two engines hold the same tensor,
308
+ which is what makes the point checkable rather than merely servable. `_tree._value_module` is the
309
+ vLLM side, and it prefers the norm in the same order for the same reason.
310
+
311
+ **Reading a norm costs a rank, and the point does not pay it.** A norm over `head_dim` can only be
312
+ given the per-head view, so both engines hand `v_norm` a `(…, n_kv_heads, head_dim)` tensor where a
313
+ `v_proj` would have produced the flat one — and `value` would then be 4-D on this family and 3-D on the
314
+ next, against a `Width.HEADS` declaration and `run_with_cache`'s `[batch, seq, width]`. Flattened back
315
+ on both sides (`hooks.flat_per_head`, `vllm_capture._hooks.flat_value`), including on the way *into* a
316
+ steer, so a vector measured on a capture of `value` is the shape the write expects; the module gets its
317
+ own rank back before the attention reshapes it. Eagerly the head count is checked rather than assumed,
318
+ because the other 4-D layout in circulation is `[batch, heads, pos, head_dim]` — transformers norms
319
+ after the head transpose on ten families — and flattening that one puts the head count where every
320
+ reader expects the sequence.
321
+
322
+ **Off Gemma-4, `value` on vLLM is one third of a packed matrix.** Every family with a fused vLLM
323
+ implementation goes through `QKVParallelLinear`, whose output is `[q | k | v]` on the last axis, so an
324
+ output hook there returns all three under the value's name at three times the width.
325
+ `_tree.value_span` measures the q and k widths off the projection's own rank-local geometry
326
+ (`num_heads`, `num_kv_heads`, `head_size`, each divided by the TP size in its `__init__`) and
327
+ `_hooks.value_columns` applies it on both install paths, refusing a geometry it can read only in part —
328
+ every wrong offset into a packed matrix yields a right-shaped tensor of another projection's heads. On
329
+ the per-request path the narrowing happens *before* steering, so a steer on `value` leaves the same
330
+ matrix's q and k alone.
331
+
332
+ The `k_eq_v` layers stay the interesting case for a *reader*: `value` there is `v_norm(k_proj(h))`
333
+ where the key is `rope(k_norm(k_proj(h)))`, so the two points come off one projection through
334
+ different norms and only one of them has RoPE applied. `ArchSpec.is_k_eq_v_layer` answers which layers
335
+ those are, gated on `not sliding` the way the modeling code gates it — the flag is model-wide and the
336
+ structure is not.
300
337
 
301
338
  Two more ways the value side can differ from the query side, both of which produce a correctly shaped
302
339
  and completely wrong per-head split if you reshape by `head_dim`:
@@ -438,7 +475,7 @@ n_experts]` tensor that is plausible rather than obviously wrong.
438
475
  which they are, because the LongCat guard makes the `mlp_pre` refusal require a router **on the MLP
439
476
  module**, and Gemma-4's is a sibling. The parameter count of a sparse layer includes the dense MLP,
440
477
  so the usual `(n_layers - n_sparse) * dense_mlp` undercounts (the 26B by ~0.5B). And `mlp_out` is
441
- **not** the whole feed-forward here, which is the one that has no fix yet — see below.
478
+ **not** the whole feed-forward here, so it is refused — see below.
442
479
 
443
480
  Which experts fired _is_ capturable, one level down: `router_logits` / `expert_weights` /
444
481
  `expert_indices` are three elements of the router module's own output tuple, resolved by
@@ -484,18 +521,33 @@ module at all. Gemma-4 is the exception noted above, and the refusal already let
484
521
  right reason rather than by luck: it is gated on the router being found *on the MLP module*, the same
485
522
  guard that keeps LongCat's shortcut MoE from suppressing its two real feed-forwards.
486
523
 
487
- **Open on Gemma-4's 26B**, both from the same fact — the routed branch is a sibling of `layer.mlp`
488
- rather than a part of it:
489
-
490
- - `mlp_out` taps `layer.mlp` and so returns the dense branch alone, missing the experts' contribution
491
- entirely. Correctly shaped, wrong tensor, no tell. Either the point has to mean the summed
492
- feed-forward here (which has no module boundary — the sum happens in the block's `forward`) or it
493
- has to be refused with an explanation, and that choice has not been made.
494
- - `arch.moe_router` looks for the router under `layer.mlp` and Gemma-4 hangs it on the block, so
495
- `router_logits` / `expert_weights` / `expert_indices` do not resolve at all. When they do, note that
496
- `Gemma4TextRouter` returns `(router_probabilities, top_k_weights, top_k_index)` — element 0 is
497
- already softmaxed over all 128 experts, so it matches the default tuple *order* while not being
498
- logits, and `assert_routing_shapes` cannot tell the difference because the width is right.
524
+ **Three things follow on Gemma-4's 26B from that one fact** — the routed branch is a sibling of
525
+ `layer.mlp` rather than a part of it, so the *block* owns both halves of the feed-forward:
526
+
527
+ - **`mlp_out` is refused**, on both backends. It taps `layer.mlp`, which here returns the dense branch
528
+ alone: correctly shaped `[tokens, d_model]`, at the right positions, missing the experts entirely,
529
+ and with nothing about it to notice — both engines build the same tree, so a cross-engine sweep
530
+ agrees on the same half rather than catching it. The summed tensor has no module boundary to serve
531
+ instead (the sum is a local of the block's `forward`), and there is already a point that means it:
532
+ `mlp_out_post` is the `post_feedforward_layernorm`'s output, downstream of the sum, and the one for
533
+ which `resid_post == resid_mid + mlp_out_post` holds. `ArchSpec.mlp_is_half_the_feed_forward` is the
534
+ question both refusals ask. Nothing else moves: `mlp_in`, `mlp_pre`, `mlp_pre_linear` and `mlp_act`
535
+ are the dense branch's own internals and mean what they mean everywhere else.
536
+ - **The router is looked for on the block as well as on the MLP** (`facts.moe_router_owner`). Asking
537
+ only `layer.mlp` left all three routing points unresolvable on the 26B. Presence alone is not enough
538
+ now that a decoder layer is searched: `facts.moe_router_attr` requires a callable submodule, because
539
+ a block can hold a plain flag under one of these names and the caller is about to install a forward
540
+ hook on whatever comes back.
541
+ - **`router_logits` is read one module deeper than the tuple.** `Gemma4TextRouter.forward` returns
542
+ `(router_probabilities, top_k_weights, top_k_index)`: element 0 matches the default tuple *order*
543
+ while being a softmax over all 128 experts, and is exactly as wide as the logits over the same bank,
544
+ so neither `ROUTER_OUTPUTS` nor a width check can see it — and the block discards it. So the point
545
+ reads the router's own `proj` output, which is what that softmax consumed and what vLLM's
546
+ `Gemma4Router.forward` returns outright, leaving the two engines comparable
547
+ (`facts.ROUTER_LOGITS_SUBMODULE`, plus a `ROUTER_OUTPUTS` row naming slot 0 for what it is).
548
+ `assert_routing_shapes` also rejects a `router_logits` that is non-negative everywhere and sums to 1
549
+ per token, which is the family-agnostic version of the same catch. Elements 1 and 2 are the weights
550
+ and indices the block really routes with, and are read where every other family's are.
499
551
 
500
552
  In our shipping set only gpt-oss-20b is MoE (32 experts, top-4, no shared expert, every layer
501
553
  sparse). Its 3D batched expert weights matter only to code reading MLP weight matrices, and the
@@ -86,6 +86,12 @@ class Quirks:
86
86
  # no branch on this -- ``mlp_in``/``mlp_out`` tap ``layer.mlp``, which is the whole block
87
87
  # including any shared expert -- so it is reported for callers, not consumed here.
88
88
  moe_layers: tuple[int, ...] = ()
89
+ # Whether a sparse layer's routed experts are added *beside* the dense MLP instead of replacing
90
+ # it (Gemma-4, and so far only Gemma-4). That inverts the sentence above: ``layer.mlp`` is then
91
+ # one of two branches the block's own forward sums, so tapping it returns half a feed-forward.
92
+ # Consumed by capture, which refuses ``mlp_out`` on such a layer and points at ``mlp_out_post``.
93
+ # See :func:`facts.dense_mlp_beside_experts`.
94
+ dense_mlp_beside_experts: bool = False
89
95
  # Each sublayer's output is normalized before being added to the residual (Gemma-2/3/4
90
96
  # sandwich norms, OLMo-2/3 post-norms), so the *residual contribution* is a different tensor
91
97
  # from the raw submodule output. Enables the ``attn_out_post`` / ``mlp_out_post`` points.
@@ -556,6 +562,10 @@ class ArchSpec:
556
562
  # did it while `mlp_in`/`mlp_out` at the same position happily resolved to that same dense
557
563
  # module, so the two halves of the MLP vocabulary contradicted each other about one block.
558
564
  mlp = self.mlp_projection_holder(layer)
565
+ # The MLP's *own* router, deliberately, not `facts.moe_router_owner`: a router beside the MLP
566
+ # rather than inside it means the dense MLP is still there and still has a neuron basis to
567
+ # capture (Gemma-4 -- see `mlp_is_half_the_feed_forward`), so widening this to the block would
568
+ # refuse three points that exist. `moe_router` widens; this one must not.
559
569
  if self.is_moe_layer(layer) and facts.moe_router_attr(mlp) is not None:
560
570
  raise ValueError(
561
571
  f"Layer {layer} of {self.architecture} is a sparse MoE block, so it has no single "
@@ -615,6 +625,17 @@ class ArchSpec:
615
625
  """Whether ``layer``'s MLP is a sparse mixture-of-experts block rather than a dense one."""
616
626
  return layer in self.quirks.moe_layers
617
627
 
628
+ def mlp_is_half_the_feed_forward(self, layer: int) -> bool:
629
+ """Whether ``layer.mlp`` is one of two feed-forward branches the block's forward sums.
630
+
631
+ True only where a sparse layer keeps its dense MLP *beside* the experts (Gemma-4): both
632
+ branches read the pre-feedforward residual through norms of their own, and the block adds
633
+ them. So ``layer.mlp`` is a complete module producing a complete ``d_model`` tensor that is
634
+ nonetheless not the layer's feed-forward -- which is why this is asked rather than inferred
635
+ from ``is_moe_layer``, true on families where the MLP *is* the whole block.
636
+ """
637
+ return self.is_moe_layer(layer) and self.quirks.dense_mlp_beside_experts
638
+
618
639
  def moe_router(self, layer: int) -> nn.Module:
619
640
  """The sparse block's router, whose output is the whole routing decision.
620
641
 
@@ -634,12 +655,16 @@ class ArchSpec:
634
655
  "the neuron basis of a dense MLP."
635
656
  )
636
657
  mlp = self.mlp_module(layer)
637
- attr = facts.moe_router_attr(mlp)
638
- if attr is None:
658
+ # Beside the MLP as well as inside it: Gemma-4 hangs the router on the block, and asking only
659
+ # the MLP reported "no router submodule found" on a checkpoint whose router is one attribute
660
+ # away. See :func:`facts.moe_router_owner`.
661
+ owner = facts.moe_router_owner(self.block(layer), mlp)
662
+ if owner is None:
639
663
  raise AttributeError(
640
- f"No router submodule found on layer {layer}'s {type(mlp).__name__} "
641
- f"({self.architecture}); tried {facts.MOE_ROUTER_ATTRS}"
664
+ f"No router submodule found on layer {layer}'s {type(mlp).__name__} or on the block "
665
+ f"itself ({self.architecture}); tried {facts.MOE_ROUTER_ATTRS}"
642
666
  )
667
+ holder, attr = owner
643
668
  if "forward" in vars(mlp):
644
669
  # A quantizer or kernel loader has replaced the block's forward on the instance, and the
645
670
  # replacements route *inline*: transformers' MXFP4 path for gpt-oss calls
@@ -676,7 +701,7 @@ class ArchSpec:
676
701
  "the fused path (for gpt-oss: quantization_config=Mxfp4Config(dequantize=True)) to get the "
677
702
  "eager router, and with it the weights and indices, back."
678
703
  )
679
- return getattr(mlp, attr)
704
+ return getattr(holder, attr)
680
705
 
681
706
  def inline_routing_logits(self, layer: int) -> tuple[nn.Module, str] | None:
682
707
  """Address of ``router_logits`` on a block that routes inline, or None if it calls its router.
@@ -696,6 +721,12 @@ class ArchSpec:
696
721
 
697
722
  Raises on a KV-shared layer, where the value tensor is genuinely produced by a different
698
723
  layer rather than merely being hard to find.
724
+
725
+ ``None`` also -- and this is why callers must ask :meth:`value_module` *first* rather than
726
+ reading None as "split a fused QKV" -- on a layer that has no value projection because it uses
727
+ the key's (:meth:`is_k_eq_v_layer`). That is a third case this return value cannot distinguish,
728
+ on a family whose :attr:`Quirks.fused_qkv` is false, so a caller reaching here with None on
729
+ Gemma-4 would go looking for a fused projection that does not exist.
699
730
  """
700
731
  if self.is_kv_shared_layer(layer):
701
732
  raise ValueError(self._kv_shared_refusal(layer, "value", "value projection", "DFA reads it too."))
@@ -953,6 +984,48 @@ class ArchSpec:
953
984
  :func:`facts.value_scale`."""
954
985
  return facts.value_scale(self.attn_module(layer))
955
986
 
987
+ def is_k_eq_v_layer(self, layer: int) -> bool:
988
+ """Whether ``layer`` takes its *key* projection's output as the value and has no ``v_proj``.
989
+
990
+ Gemma-4's ``attention_k_eq_v``, and only on the layers the modeling code applies it to: the
991
+ flag is model-wide but ``Gemma4TextAttention`` gates it on ``not is_sliding``, so a sliding
992
+ layer of the same checkpoint projects its value normally. Reading the flag alone would
993
+ describe every layer of the 12B, 26B and 31B as having no value projection when half of them
994
+ do -- and the sliding layers' ``num_key_value_heads`` differs from the global ones', so the
995
+ two kinds are not interchangeable in any case (:meth:`kv_heads_for_layer`).
996
+ """
997
+ if not self.k_eq_v:
998
+ return False
999
+ kinds = self.quirks.hybrid_layer_types
1000
+ if not kinds or layer >= len(kinds):
1001
+ # The flag with no layer table is a homogeneous trunk: nothing marks a layer sliding, so
1002
+ # the alternative attention applies throughout.
1003
+ return True
1004
+ return "sliding" not in str(kinds[layer]).lower()
1005
+
1006
+ def value_module(self, layer: int) -> nn.Module | None:
1007
+ """The module whose output is the value attention consumes, when that is not the projection.
1008
+
1009
+ The norm the family runs between projection and attention -- Gemma-4's ``v_norm`` -- or
1010
+ ``None`` on every family where ``value`` is the projection's own output.
1011
+
1012
+ Preferred over :meth:`v_proj` wherever it exists, on *all* of that family's layers rather
1013
+ than only the ones missing a projection. Two reasons, and the first would apply even if the
1014
+ second never came up: the normed tensor is the one the attention pattern multiplies, so it is
1015
+ what ``value`` names and what DFA needs; and vLLM has no separate value projection to compare
1016
+ against (its ``Gemma4Attention`` splits a fused QKV) but runs the same ``v_norm``, so this is
1017
+ the only boundary at which the two engines can be checked against each other at all.
1018
+
1019
+ Raises on a KV-shared layer, where the tensor is genuinely another layer's -- the same
1020
+ refusal :meth:`v_proj` makes, and for the same reason: those layers are built with neither a
1021
+ projection nor a norm.
1022
+ """
1023
+ if self.is_kv_shared_layer(layer):
1024
+ raise ValueError(self._kv_shared_refusal(layer, "value", "value norm", "DFA reads it too."))
1025
+ attn = self.attn_module(layer)
1026
+ attr = facts.value_norm_attr(attn)
1027
+ return getattr(attn, attr) if attr is not None else None
1028
+
956
1029
  def is_kv_shared_layer(self, layer: int) -> bool:
957
1030
  """Whether ``layer`` reuses an earlier layer's keys/values and has no k/v projection."""
958
1031
  return self.first_kv_shared_layer is not None and layer >= self.first_kv_shared_layer
@@ -1127,6 +1200,7 @@ def resolve_arch(model: nn.Module, config: Any) -> ArchSpec:
1127
1200
  qkv_layout=facts.eager_qkv_layout(architecture, config),
1128
1201
  gated_attn_out=gated_attn_out,
1129
1202
  moe_layers=model_facts.moe_layers,
1203
+ dense_mlp_beside_experts=model_facts.dense_mlp_beside_experts,
1130
1204
  sandwich_norms=post_mlp_norm_attr is not None,
1131
1205
  tied_embeddings=tied,
1132
1206
  attn_sinks=bool(hints.get("attn_sinks", False)),
@@ -27,7 +27,7 @@ from interp_engine.address import Address, to_address
27
27
  from interp_engine.attn_scores import capture_attn_scores
28
28
  from interp_engine.dispatch import TokensLike, as_batched_tokens, as_token_ids, refuse
29
29
  from interp_engine.facts import text_config
30
- from interp_engine.hooks import HookManager
30
+ from interp_engine.hooks import HookManager, flat_per_head
31
31
  from interp_engine.model import EagerModel
32
32
  from interp_engine.points import token_flattened
33
33
  from interp_engine.protocol import InterpModel
@@ -386,6 +386,13 @@ def _run_with_cache_eager(
386
386
  heads = model.arch.kv_heads_for_layer(key.layer or 0) if key.name.startswith("k_") else model.n_heads
387
387
  cache.tensors[key] = _to_token_major(cache.tensors[key], seq, heads)
388
388
 
389
+ for key in {k for k in cache.tensors if k.name == "value"}:
390
+ # Flat, however the family produced it: a value norm sees the per-head view and a value
391
+ # projection does not, and `value` is one point. See `hooks.flat_per_head`.
392
+ layer = key.layer or 0
393
+ flat, _ = flat_per_head(cache.tensors[key], heads=model.arch.kv_heads_for_layer(layer))
394
+ cache.tensors[key] = flat
395
+
389
396
  if wants_attn:
390
397
  attentions = output.attentions # one [batch, n_heads, q, k] per softmax-attention layer
391
398
  for address in addresses:
@@ -1134,9 +1134,46 @@ def moe_router_attr(mlp: Any) -> str | None:
1134
1134
  """Attribute name of the sparse block's router, or ``None`` if this MLP has none.
1135
1135
 
1136
1136
  ``None`` on every dense MLP, including the dense prefix layers of a hybrid MoE model, so a
1137
- caller must not read it as "this checkpoint is not MoE".
1137
+ caller must not read it as "this checkpoint is not MoE". Also ``None`` on a Gemma-4 sparse layer,
1138
+ whose router is a sibling of the MLP rather than a child of it -- ask :func:`moe_router_owner`,
1139
+ which looks in both places, unless you specifically mean the MLP's own.
1138
1140
  """
1139
- return next((name for name in MOE_ROUTER_ATTRS if hasattr(mlp, name)), None)
1141
+ return next((name for name in MOE_ROUTER_ATTRS if _is_module(getattr(mlp, name, None))), None)
1142
+
1143
+
1144
+ def _is_module(candidate: Any) -> bool:
1145
+ """Whether this attribute is a callable submodule rather than a flag or a tensor.
1146
+
1147
+ Duck-typed because this module holds no torch dependency (see the header). Presence alone is not
1148
+ enough once the *block* is searched for a router as well as the MLP: a decoder layer can hold a
1149
+ plain attribute under one of these names, and ``hasattr`` would hand a bool back to a caller
1150
+ about to install a forward hook on it.
1151
+ """
1152
+ return callable(candidate) and hasattr(candidate, "forward")
1153
+
1154
+
1155
+ def moe_router_owner(layer: Any, mlp: Any) -> tuple[Any, str] | None:
1156
+ """The module that holds the sparse block's router, and the attribute name -- or ``None``.
1157
+
1158
+ The MLP first, because that is where every other MoE family puts it: the router is a child of
1159
+ the ``MixtralSparseMoeBlock`` / ``Qwen3MoeSparseMoeBlock`` / ``GptOssMLP`` that consumes it, so
1160
+ on those the block is never searched and nothing about them changes.
1161
+
1162
+ Gemma-4 is the family that needs the second look. Its router is a sibling of ``layer.mlp``
1163
+ (``layer.router``, beside ``layer.experts``), for the same reason its MLP is only half the
1164
+ feed-forward: the routed branch is assembled by the *block's* forward, so the block owns both
1165
+ halves and neither is inside the other. Asking only the MLP left all three routing points
1166
+ unresolvable on the 26B, reported as "no router submodule found" -- a lookup failure that reads
1167
+ like a checkpoint without a router rather than a router one level up.
1168
+
1169
+ ``None`` on a dense layer either way, which a caller must not read as "this checkpoint is not
1170
+ MoE": the dense prefix of a hybrid trunk answers ``None`` too.
1171
+ """
1172
+ if (attr := moe_router_attr(mlp)) is not None:
1173
+ return mlp, attr
1174
+ if layer is not None and (attr := moe_router_attr(layer)) is not None:
1175
+ return layer, attr
1176
+ return None
1140
1177
 
1141
1178
 
1142
1179
  #: Sparse-block ``forward`` replacements that route *inline* -- computing the logits with the router's
@@ -1188,16 +1225,59 @@ def routing_convention(architecture: str) -> str | None:
1188
1225
  #: outside: element 0 is ``[tokens, k]`` where the default reading expects ``[tokens, n_experts]``,
1189
1226
  #: which is a plausible tensor under either name, and it took a cross-engine width mismatch to catch.
1190
1227
  #: :func:`assert_routing_shapes` is the check that makes the next one of these loud instead.
1228
+ #: A name for an element that is not one of the three points -- it is a real tensor a router returns
1229
+ #: which no canonical point means, and it exists so that a layout can say "the logits are *not* here"
1230
+ #: rather than leaving the slot labelled with the point that would then be read out of it.
1231
+ _NOT_A_POINT_PROBS = "router_probabilities"
1191
1232
  _DEFAULT_ROUTER_OUTPUT: tuple[str, ...] = ("router_logits", "expert_weights", "expert_indices")
1192
1233
  ROUTER_OUTPUTS: dict[str, tuple[str, ...]] = {
1193
1234
  "GraniteMoeForCausalLM": ("expert_indices", "expert_weights", "router_logits"),
1194
1235
  "GraniteMoeSharedForCausalLM": ("expert_indices", "expert_weights", "router_logits"),
1195
1236
  "GraniteMoeHybridForCausalLM": ("expert_indices", "expert_weights", "router_logits"),
1237
+ # Gemma-4 returns the three tensors in the default *order* while element 0 is not the default
1238
+ # tensor: `Gemma4TextRouter.forward` softmaxes over all 128 experts and returns those
1239
+ # probabilities there. The width check cannot catch it -- probabilities over the bank are exactly
1240
+ # as wide as logits over the bank -- and neither can a caller downstream, since both are
1241
+ # per-token float rows whose entries are plausible either way. The block itself discards element
1242
+ # 0, so it is not even a tensor the model uses. Its logits are one module deeper
1243
+ # (:data:`ROUTER_LOGITS_SUBMODULE`), which is where `router_logits` resolves on this family.
1244
+ "Gemma4ForConditionalGeneration": (_NOT_A_POINT_PROBS, "expert_weights", "expert_indices"),
1245
+ "Gemma4UnifiedForConditionalGeneration": (_NOT_A_POINT_PROBS, "expert_weights", "expert_indices"),
1246
+ "Gemma4ForCausalLM": (_NOT_A_POINT_PROBS, "expert_weights", "expert_indices"),
1196
1247
  }
1197
1248
 
1198
1249
 
1250
+ #: Where a family's *pre-softmax* logits are, for the families whose router module does not return
1251
+ #: them: the attribute, on the router, of the projection that produces them.
1252
+ #:
1253
+ #: Gemma-4 is the family (see :data:`ROUTER_OUTPUTS`). ``Gemma4TextRouter`` norms, scales and then
1254
+ #: projects, and it is that ``proj`` whose output the softmax consumes -- so this is a *read* of the
1255
+ #: tensor the routing decision was made from, not a recomputation of it, and it is bit-identical to
1256
+ #: what vLLM's own ``Gemma4Router.forward`` returns, which makes the two engines comparable at this
1257
+ #: point rather than only within one.
1258
+ #:
1259
+ #: Keyed by architecture and deliberately small, like every other table here. A family whose router
1260
+ #: returns its logits needs no entry, and guessing an entry for one would address a point at a
1261
+ #: submodule whose output has never been checked against what the family routes on.
1262
+ ROUTER_LOGITS_SUBMODULE: dict[str, str] = {
1263
+ "Gemma4ForConditionalGeneration": "proj",
1264
+ "Gemma4UnifiedForConditionalGeneration": "proj",
1265
+ "Gemma4ForCausalLM": "proj",
1266
+ }
1267
+
1268
+
1269
+ def router_logits_submodule(architecture: str) -> str | None:
1270
+ """The router submodule holding this family's pre-softmax logits, or None if the router returns them."""
1271
+ return ROUTER_LOGITS_SUBMODULE.get(architecture)
1272
+
1273
+
1199
1274
  def router_output_index(architecture: str, point: str) -> int:
1200
- """Which element of this family's router output tuple carries ``point``."""
1275
+ """Which element of this family's router output tuple carries ``point``.
1276
+
1277
+ Raises where the tuple carries no such tensor, which is a real answer and not a gap in the table:
1278
+ a family can return probabilities where the default returns logits, and the caller then has to go
1279
+ somewhere else for them (:func:`router_logits_submodule`) rather than read the slot.
1280
+ """
1201
1281
  layout = ROUTER_OUTPUTS.get(architecture, _DEFAULT_ROUTER_OUTPUT)
1202
1282
  if point not in layout:
1203
1283
  raise ValueError(f"{architecture}'s router output carries {layout}, not {point!r}")
@@ -1221,6 +1301,16 @@ def assert_routing_shapes(point: str, tensor: Any, *, architecture: str, n_exper
1221
1301
  f"Its router's output tuple is not {_DEFAULT_ROUTER_OUTPUT}; register the real order in "
1222
1302
  "`facts.ROUTER_OUTPUTS`."
1223
1303
  )
1304
+ if point == "router_logits" and _looks_like_a_distribution(tensor):
1305
+ raise ValueError(
1306
+ f"{architecture}'s captured 'router_logits' is non-negative everywhere and sums to 1 per "
1307
+ "token, so it is a softmax over the expert bank rather than the logits that went into one. "
1308
+ "A family can return the probabilities where the default tuple returns logits (Gemma-4 "
1309
+ "does, and the width check above cannot see it, because a distribution over the bank is "
1310
+ "exactly as wide as the logits over it). Point 'router_logits' at the router's own "
1311
+ "projection with `facts.ROUTER_LOGITS_SUBMODULE` and mark the slot in "
1312
+ "`facts.ROUTER_OUTPUTS`."
1313
+ )
1224
1314
  if point == "expert_indices" and tensor.dtype.is_floating_point:
1225
1315
  raise ValueError(
1226
1316
  f"{architecture}'s captured 'expert_indices' is {tensor.dtype}, and a selection is integers. "
@@ -1234,6 +1324,28 @@ def assert_routing_shapes(point: str, tensor: Any, *, architecture: str, n_exper
1234
1324
  )
1235
1325
 
1236
1326
 
1327
+ def _looks_like_a_distribution(tensor: Any) -> bool:
1328
+ """Whether every row of ``tensor``'s last axis is non-negative and sums to one.
1329
+
1330
+ The tell that separates a softmax's output from its input, and the only property that does: the
1331
+ two are the same shape, the same dtype and the same width, and both are per-token float rows whose
1332
+ entries look like scores. Both halves are needed -- logits happen to sum near 1 sometimes, and a
1333
+ non-negative row need not be normalized -- and together they are a thing real logits over 128
1334
+ experts do not do.
1335
+
1336
+ Written with no torch dependency (see the header) and defensively: anything that does not answer
1337
+ these questions is not a distribution as far as this is concerned, because the caller's job is a
1338
+ shape assertion and it must not be the thing that raises.
1339
+ """
1340
+ try:
1341
+ if tensor.dtype.is_floating_point is False:
1342
+ return False
1343
+ as_float = tensor.detach().float()
1344
+ return bool((as_float >= 0).all()) and bool(((as_float.sum(-1) - 1.0).abs() < 1e-3).all())
1345
+ except (AttributeError, RuntimeError, TypeError):
1346
+ return False
1347
+
1348
+
1237
1349
  def inline_routing_logits_index(mlp: Any) -> int | None:
1238
1350
  """Where this block's own output holds the router logits, when its forward routes inline.
1239
1351
 
@@ -1286,6 +1398,24 @@ def dense_mlp_beside_experts(cfg: Any) -> bool:
1286
1398
  return bool(config_attr(cfg, "enable_moe_block", False))
1287
1399
 
1288
1400
 
1401
+ #: The attribute both engines' Gemma-4 decoder layers set from ``enable_moe_block``, marking a block
1402
+ #: that hangs the routed branch beside its dense MLP. A *layer* flag rather than a config read, which
1403
+ #: is what the vLLM worker needs: it holds a block with no layer index to ask
1404
+ #: :func:`is_moe_layer` about, and the two trees set this from the same config field, so asking the
1405
+ #: block cannot disagree with what the eager backend concluded from the config.
1406
+ DENSE_MLP_BESIDE_EXPERTS_FLAG = "enable_moe_block"
1407
+
1408
+
1409
+ def experts_beside_this_layers_mlp(layer: Any) -> bool:
1410
+ """Whether *this* block sums a routed branch with its dense MLP's output.
1411
+
1412
+ False on a Gemma-4 layer that is dense, so this is per block rather than per checkpoint -- and
1413
+ false everywhere outside the family, where a sparse layer has no dense MLP to sum with. See
1414
+ :func:`dense_mlp_beside_experts` for what the arrangement is and why it needs saying.
1415
+ """
1416
+ return bool(getattr(layer, DENSE_MLP_BESIDE_EXPERTS_FLAG, False))
1417
+
1418
+
1289
1419
  def is_moe_layer(cfg: Any, layer: int) -> bool:
1290
1420
  """Whether ``layer``'s ``mlp`` is a sparse MoE block rather than a dense MLP.
1291
1421
 
@@ -1432,6 +1562,41 @@ def value_scale(attn_module: Any) -> float:
1432
1562
  return 1.0
1433
1563
 
1434
1564
 
1565
+ #: A norm applied to the value vectors *between* the projection and attention, so the tensor
1566
+ #: attention consumes is this module's output rather than the projection's. Gemma-4 is the family:
1567
+ #: ``Gemma4TextAttention.forward`` runs ``value_states = self.v_norm(value_states)`` on every layer
1568
+ #: that projects its own KV, and vLLM's ``Gemma4Attention`` runs the same line on the V slice of its
1569
+ #: fused QKV -- so the two engines agree here in a way they cannot at the projection, which vLLM does
1570
+ #: not have separately.
1571
+ #:
1572
+ #: The same idea as :data:`ATTN_VALUE_SCALE_ATTRS`, one step further: that corrects a scalar the
1573
+ #: forward applies after the projection, and this names a module that does. Kept as a module rather
1574
+ #: than folded into the scale because an RMS norm is per token, not a constant, so no factor
1575
+ #: reproduces it.
1576
+ #:
1577
+ #: Exact names, and only the ones verified against a family's forward. A ``v_norm`` that a forward
1578
+ #: does *not* apply to the value would make this the wrong tensor -- silently, since it is the right
1579
+ #: shape -- which is the same trap ``use_qk_norm=True`` beside an ``nn.Identity`` sets.
1580
+ ATTN_VALUE_NORM_ATTRS: tuple[str, ...] = ("v_norm",)
1581
+
1582
+
1583
+ def value_norm_attr(attn_module: Any) -> str | None:
1584
+ """Attribute name of the norm this layer's value passes through, or ``None`` if there is none.
1585
+
1586
+ ``None`` on every family but Gemma-4, where ``value`` is the projection's own output and nothing
1587
+ about the resolution changes.
1588
+
1589
+ Two things this buys on Gemma-4, and the second is why it is a module question rather than a
1590
+ correction applied afterwards. The value it names is the one attention multiplies the pattern by,
1591
+ where ``v_proj``'s output is a norm short of it -- true on *every* layer of the family, including
1592
+ the sliding ones that do have a ``v_proj``. And on a ``attention_k_eq_v`` layer there is no
1593
+ ``v_proj`` at all: the forward passes the *key* projection's output to ``v_norm``, so this module
1594
+ is the only boundary the value crosses, and asking for the projection there is a question with no
1595
+ answer (see :meth:`interp_engine.arch.ArchSpec.is_k_eq_v_layer`).
1596
+ """
1597
+ return next((name for name in ATTN_VALUE_NORM_ATTRS if _is_module(getattr(attn_module, name, None))), None)
1598
+
1599
+
1435
1600
  def value_head_dim(cfg: Any, head_dim: int) -> int:
1436
1601
  """The width of one *value* head, which is not always the query/key head's width.
1437
1602
 
@@ -46,6 +46,35 @@ def extract_hidden(output: object, index: int = 0) -> torch.Tensor:
46
46
  return output # type: ignore[return-value]
47
47
 
48
48
 
49
+ def flat_per_head(tensor: torch.Tensor, *, heads: int) -> tuple[torch.Tensor, torch.Size | None]:
50
+ """A capture that arrived with a head axis, as the flat ``[batch, pos, heads * head_dim]`` a point
51
+ declares -- with the shape to restore, for a hook that has to write back.
52
+
53
+ `value` needs this and `z` does not, for a reason that is about modules rather than about heads.
54
+ The module `value` resolves to is a *norm* wherever the family has one, and a norm over `head_dim`
55
+ has to be given the per-head view: Gemma-4 runs `v_norm` on `v_proj(h).view(..., n_kv, head_dim)`
56
+ and vLLM runs the same norm on `v.unflatten(-1, (n_kv, head_dim))`. So the point arrives per head
57
+ on one family and flat on the next, while `Width.HEADS` and `run_with_cache` both promise one
58
+ rank -- and TransformerLens' `hook_v` is the per-head spelling of this point, which is a
59
+ distinction the mapping table would lose if ours moved family to family.
60
+
61
+ The head count is checked rather than assumed, because the other 4-D layout a norm can produce is
62
+ `[batch, heads, pos, head_dim]` (transformers norms after the head transpose on ten families), and
63
+ flattening *that* yields a tensor whose second axis is the head count. Every reader would take it
64
+ for the sequence, `[batch, seq, width]` would still describe it, and nothing downstream could see
65
+ it was wrong.
66
+ """
67
+ if tensor.ndim != 4:
68
+ return tensor, None
69
+ if tensor.shape[-2] != heads:
70
+ raise ValueError(
71
+ f"Expected a per-head capture with {heads} heads on its second-to-last axis, got "
72
+ f"{tuple(tensor.shape)}. A head-major layout ([batch, heads, pos, head_dim]) is the "
73
+ "likely reason, and flattening it would put the head count where the sequence belongs."
74
+ )
75
+ return tensor.flatten(-2, -1), tensor.shape[-2:]
76
+
77
+
49
78
  def parse_point(point: str) -> tuple[str, int]:
50
79
  """Split a hook side into ``(side, tuple index)``: ``"output"`` -> ``("output", 0)``,
51
80
  ``"output:2"`` -> ``("output", 2)``.