interp-engine 1.3.3__tar.gz → 1.3.5__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.3 → interp_engine-1.3.5}/PKG-INFO +1 -1
  2. {interp_engine-1.3.3 → interp_engine-1.3.5}/docs/ARCHITECTURE_QUIRKS.md +49 -6
  3. {interp_engine-1.3.3 → interp_engine-1.3.5}/docs/INTERNALS.md +6 -0
  4. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/arch.py +20 -5
  5. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/facts.py +74 -10
  6. interp_engine-1.3.5/interp_engine/notebook_stdout.py +90 -0
  7. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_backend.py +19 -2
  8. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/static.py +4 -1
  9. {interp_engine-1.3.3 → interp_engine-1.3.5}/pyproject.toml +1 -1
  10. interp_engine-1.3.5/tests/test_notebook_stdout.py +132 -0
  11. {interp_engine-1.3.3 → interp_engine-1.3.5}/.gitignore +0 -0
  12. {interp_engine-1.3.3 → interp_engine-1.3.5}/LICENSE +0 -0
  13. {interp_engine-1.3.3 → interp_engine-1.3.5}/README.md +0 -0
  14. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/README.md +0 -0
  15. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/__init__.py +0 -0
  16. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/bench_spec.py +0 -0
  17. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/cells.py +0 -0
  18. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/probe.py +0 -0
  19. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/probe_lens_stream.py +0 -0
  20. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/publish.py +0 -0
  21. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/report_bench.py +0 -0
  22. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/deepseek-v4-flash-0731__eager.json +0 -0
  23. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/deepseek-v4-flash-0731__vllm-cudagraph.json +0 -0
  24. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark-cudagraph.json +0 -0
  25. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark.json +0 -0
  26. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/deepseek-v4-flash-0731__vllm-static.json +0 -0
  27. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/deepseek-v4-flash-0731__vllm.json +0 -0
  28. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/gemma-2-2b__eager.json +0 -0
  29. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/gemma-2-2b__vllm-cudagraph.json +0 -0
  30. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/gemma-2-2b__vllm-static.json +0 -0
  31. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/gemma-2-2b__vllm.json +0 -0
  32. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/llama-3.1-8b__eager.json +0 -0
  33. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/llama-3.1-8b__vllm-cudagraph.json +0 -0
  34. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/llama-3.1-8b__vllm-static.json +0 -0
  35. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/llama-3.1-8b__vllm.json +0 -0
  36. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/qwen3-4b__eager.json +0 -0
  37. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/qwen3-4b__vllm-cudagraph.json +0 -0
  38. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/qwen3-4b__vllm-static.json +0 -0
  39. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/qwen3-4b__vllm.json +0 -0
  40. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/qwen3.8-27b__eager.json +0 -0
  41. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/qwen3.8-27b__vllm-cudagraph.json +0 -0
  42. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/qwen3.8-27b__vllm-static.json +0 -0
  43. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results/qwen3.8-27b__vllm.json +0 -0
  44. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/results-latest.md +0 -0
  45. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/run_all.sh +0 -0
  46. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/run_bench.py +0 -0
  47. {interp_engine-1.3.3 → interp_engine-1.3.5}/benchmarks/workloads.py +0 -0
  48. {interp_engine-1.3.3 → interp_engine-1.3.5}/docs/AGENT_INTEGRATION.md +0 -0
  49. {interp_engine-1.3.3 → interp_engine-1.3.5}/docs/COMPATIBILITY.md +0 -0
  50. {interp_engine-1.3.3 → interp_engine-1.3.5}/docs/ENGINE_HOOK_MAPPINGS.md +0 -0
  51. {interp_engine-1.3.3 → interp_engine-1.3.5}/docs/GRADIENTS.md +0 -0
  52. {interp_engine-1.3.3 → interp_engine-1.3.5}/docs/PERFORMANCE.md +0 -0
  53. {interp_engine-1.3.3 → interp_engine-1.3.5}/docs/PORTING.md +0 -0
  54. {interp_engine-1.3.3 → interp_engine-1.3.5}/docs/README.md +0 -0
  55. {interp_engine-1.3.3 → interp_engine-1.3.5}/docs/SUPPORTED_POINTS.md +0 -0
  56. {interp_engine-1.3.3 → interp_engine-1.3.5}/docs/USAGE.md +0 -0
  57. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/__init__.py +0 -0
  58. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/_loop.py +0 -0
  59. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/address.py +0 -0
  60. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/attn_config.py +0 -0
  61. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/attn_scores.py +0 -0
  62. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/autograd_support.py +0 -0
  63. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/capture.py +0 -0
  64. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/chat_compose.py +0 -0
  65. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/chat_conventions.py +0 -0
  66. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/chat_formatters.py +0 -0
  67. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/cuda_preflight.py +0 -0
  68. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/dispatch.py +0 -0
  69. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/hooks.py +0 -0
  70. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/lens.py +0 -0
  71. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/load.py +0 -0
  72. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/mappers.py +0 -0
  73. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/model.py +0 -0
  74. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/moe_routing.py +0 -0
  75. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/points.py +0 -0
  76. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/protocol.py +0 -0
  77. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/residual_basis.py +0 -0
  78. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/select.py +0 -0
  79. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/steer.py +0 -0
  80. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/steer_specs.py +0 -0
  81. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/sync.py +0 -0
  82. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/tokenize.py +0 -0
  83. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/__init__.py +0 -0
  84. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/_demux.py +0 -0
  85. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/_hooks.py +0 -0
  86. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/_payload.py +0 -0
  87. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/_tree.py +0 -0
  88. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/attn.py +0 -0
  89. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/capture.py +0 -0
  90. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/graphs.py +0 -0
  91. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/lens/__init__.py +0 -0
  92. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/lens/intervene.py +0 -0
  93. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/lens/readout.py +0 -0
  94. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/lens/unembed.py +0 -0
  95. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/mhc.py +0 -0
  96. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/native.py +0 -0
  97. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/requests.py +0 -0
  98. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_capture/steering.py +0 -0
  99. {interp_engine-1.3.3 → interp_engine-1.3.5}/interp_engine/vllm_plugin.py +0 -0
  100. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/conftest.py +0 -0
  101. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/harness.py +0 -0
  102. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/model_expectations.yaml +0 -0
  103. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/synthetic_families.py +0 -0
  104. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_address.py +0 -0
  105. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_attn_config_tripwire.py +0 -0
  106. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_attn_probs_indexing.py +0 -0
  107. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_attn_scores.py +0 -0
  108. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_attn_z_gqa.py +0 -0
  109. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_autograd_support.py +0 -0
  110. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_bench_workloads.py +0 -0
  111. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_capability_refusals.py +0 -0
  112. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_capture_addressing.py +0 -0
  113. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_chat_compose.py +0 -0
  114. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_chat_formatters.py +0 -0
  115. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_chat_templates.py +0 -0
  116. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_core.py +0 -0
  117. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_cuda_preflight.py +0 -0
  118. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_doc_code_fences.py +0 -0
  119. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_eager_autograd.py +0 -0
  120. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_facts.py +0 -0
  121. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_family_points.py +0 -0
  122. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_gated_attn_out.py +0 -0
  123. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_head_contributions.py +0 -0
  124. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_hook_call_conventions.py +0 -0
  125. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_layer_kinds.py +0 -0
  126. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_load.py +0 -0
  127. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_logit_transform.py +0 -0
  128. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_mappers.py +0 -0
  129. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_mlp_internals.py +0 -0
  130. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_model_expectations.py +0 -0
  131. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_moe.py +0 -0
  132. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_multimodal_arch.py +0 -0
  133. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_new_models_gpu.py +0 -0
  134. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_no_chat_template.py +0 -0
  135. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_normalized_hook.py +0 -0
  136. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_packaging.py +0 -0
  137. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_parity_gpt2.py +0 -0
  138. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_per_layer_attn_dims.py +0 -0
  139. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_points_registry.py +0 -0
  140. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_protocol.py +0 -0
  141. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_published_benchmarks.py +0 -0
  142. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_qk_norm.py +0 -0
  143. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_qkv_layout.py +0 -0
  144. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_reasoning_spans.py +0 -0
  145. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_release.py +0 -0
  146. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_resid_mid.py +0 -0
  147. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_residual_basis.py +0 -0
  148. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_sandwich_norms.py +0 -0
  149. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_select.py +0 -0
  150. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_sliding_window_attn.py +0 -0
  151. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_small_models_gpu.py +0 -0
  152. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_static_dsv4_gpu.py +0 -0
  153. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_static_parity_gpu.py +0 -0
  154. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_static_set.py +0 -0
  155. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_static_warmup.py +0 -0
  156. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_steer_context.py +0 -0
  157. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_steer_math_parity.py +0 -0
  158. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_sync_loop.py +0 -0
  159. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_sync_parity.py +0 -0
  160. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_unified_free_functions.py +0 -0
  161. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_unresolved_families.py +0 -0
  162. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vllm_capture_gpu.py +0 -0
  163. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vllm_capture_scales.py +0 -0
  164. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vllm_engine_loop.py +0 -0
  165. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vllm_graph_path.py +0 -0
  166. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vllm_graphs_on_gpu.py +0 -0
  167. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vllm_hook_availability.py +0 -0
  168. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vllm_hyper_connections.py +0 -0
  169. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vllm_kv_isolation.py +0 -0
  170. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vllm_new_points.py +0 -0
  171. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vllm_only_families.py +0 -0
  172. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vllm_plugin.py +0 -0
  173. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vllm_wire_grammar.py +0 -0
  174. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_vocabulary_boundary.py +0 -0
  175. {interp_engine-1.3.3 → interp_engine-1.3.5}/tests/test_worker_lens_capture_readout.py +0 -0
  176. {interp_engine-1.3.3 → interp_engine-1.3.5}/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.3
3
+ Version: 1.3.5
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
@@ -277,9 +277,26 @@ is therefore asked per side (`facts.has_qk_norm(attn, "q")`), because the pair-w
277
277
  "no" for such a layer and refusing the query norm on the key norm's absence declines a tensor the model
278
278
  plainly computes.
279
279
 
280
- Related config fields on this family that nothing reads yet: `attention_k_eq_v` (with
281
- `num_global_key_value_heads`) makes `v_proj` **None** on non-sliding layers and sets `value = key`;
282
- E2B and E4B both ship with it off.
280
+ - **`kv_heads_for_layer(layer)`** — the kv-head count moves with the head width, and in the opposite
281
+ direction: 4 against 16 on the 31B, 2 against 8 on the 26B, 1 against 8 on the 12B. transformers
282
+ 5.15 states this per layer (`per_layer_config`); below that the family spells it
283
+ `num_global_key_value_heads`, which `Gemma4TextAttention` applies to a layer only when
284
+ `attention_k_eq_v` is set **and** the layer is not sliding — so `ArchSpec` carries the flag beside
285
+ the count rather than reading the count on its own.
286
+
287
+ `attention_k_eq_v` is on for the 26B, 31B and 12B, and off for E2B and E4B. It is a second, separate
288
+ way for a layer to have no `v_proj`, and it does not overlap with KV sharing: those three SKUs set
289
+ `num_kv_shared_layers: 0`, so **every** one of their `full_attention` layers is built with
290
+ `v_proj = None` and takes the key projection's output as the value instead — `v_norm(k_proj(h))`
291
+ against a key of `rope(k_norm(k_proj(h)))`, so the two are the same projection read through
292
+ different norms, not the same tensor.
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.
283
300
 
284
301
  Two more ways the value side can differ from the query side, both of which produce a correctly shaped
285
302
  and completely wrong per-head split if you reshape by `head_dim`:
@@ -408,9 +425,20 @@ n_experts]` tensor that is plausible rather than obviously wrong.
408
425
  Mistral-4, dots1, GLM-4.5) or **every k-th layer sparse with a dense opt-out list** (`(layer + 1)
409
426
  % decoder_sparse_step == 0 and layer not in mlp_only_layers`; Qwen2/3-MoE, Qwen3-Next,
410
427
  Qwen3-VL/Omni). Expert counts have four live spellings (`num_local_experts`, `num_experts`,
411
- `n_routed_experts`, `moe_num_experts`), all read. A third idiom, an explicit `mlp_layer_types`
412
- pattern, wins over both — see the spelling warning in [block
428
+ `n_routed_experts`, `moe_num_experts`), all read, and the top-k has four of its own
429
+ (`num_experts_per_tok`, `experts_per_token`, `moe_topk`, `top_k_experts`). A third idiom, an
430
+ explicit `mlp_layer_types` pattern, wins over both — see the spelling warning in [block
413
431
  types](#block-types-classify-them-do-not-pattern-match-them).
432
+ - **Gemma-4 breaks the first sentence of this section.** Its sparse layers do not swap the MLP for an
433
+ expert bank: `Gemma4TextDecoderLayer` builds `self.mlp` on every layer and, where
434
+ `enable_moe_block` is set, hangs `self.router` and `self.experts` **beside** it on the block,
435
+ summing the two branches (`post_ffn_norm_1(mlp(x)) + post_ffn_norm_2(experts(x))`, both reading the
436
+ pre-feedforward residual). Three consequences, and `facts.dense_mlp_beside_experts` is the fact they
437
+ hang off. A sparse layer keeps a real neuron basis, so `mlp_pre`/`mlp_act` must stay served there —
438
+ which they are, because the LongCat guard makes the `mlp_pre` refusal require a router **on the MLP
439
+ module**, and Gemma-4's is a sibling. The parameter count of a sparse layer includes the dense MLP,
440
+ 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.
414
442
 
415
443
  Which experts fired _is_ capturable, one level down: `router_logits` / `expert_weights` /
416
444
  `expert_indices` are three elements of the router module's own output tuple, resolved by
@@ -452,7 +480,22 @@ router_logits)`. `facts.ROUTER_OUTPUTS` holds the exceptions; `facts.assert_rout
452
480
 
453
481
  The MLP's neuron basis (`mlp_pre`, `mlp_pre_linear`, `mlp_act`) is refused on a sparse layer, since
454
482
  the projections live on the experts — often as one fused 3-D parameter per bank, with no per-expert
455
- module at all.
483
+ module at all. Gemma-4 is the exception noted above, and the refusal already lets it through for the
484
+ right reason rather than by luck: it is gated on the router being found *on the MLP module*, the same
485
+ guard that keeps LongCat's shortcut MoE from suppressing its two real feed-forwards.
486
+
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.
456
499
 
457
500
  In our shipping set only gpt-oss-20b is MoE (32 experts, top-4, no shared expert, every layer
458
501
  sparse). Its 3D batched expert weights matter only to code reading MLP weight matrices, and the
@@ -66,6 +66,12 @@ files and why they are where they are.
66
66
  - `LD_LIBRARY_PATH`) before the first CUDA call, instead of failing ten frames deep in
67
67
  `torch.cuda._lazy_init`. Lives here because every app on the engine inherits the same CUDA
68
68
  floor — the `[vllm]` wheels link `libcudart.so.13` directly.
69
+ - `notebook_stdout.py` — `ensure_stdout_descriptor`: gives a notebook kernel's `sys.stdout` the file
70
+ descriptor vLLM's engine start needs. vLLM silences C-level output by dup'ing over one and forks
71
+ its EngineCore child, so under ipykernel — whose stream writes to a socket and has none — the
72
+ child dies before it loads anything, and the caller is told to see a root cause that is in another
73
+ process. Called from `_ensure_engine`; a no-op in a script, a server, or a kernel that captured
74
+ the real descriptors itself.
69
75
 
70
76
  ## Correctness
71
77
 
@@ -324,6 +324,11 @@ class ArchSpec:
324
324
  # elsewhere; use :meth:`head_dim_for_layer` / :meth:`kv_heads_for_layer`.
325
325
  per_layer_head_dim: tuple[int, ...] = ()
326
326
  per_layer_kv_heads: tuple[int, ...] = ()
327
+ # Gemma-4's ``num_global_key_value_heads`` and ``attention_k_eq_v``: the older spelling of the
328
+ # per-layer kv-head count, and the flag that decides whether it applies. See
329
+ # :func:`facts.kv_heads_for_layer`.
330
+ global_kv_heads: int | None = None
331
+ k_eq_v: bool = False
327
332
  # The width of one *value* head, where the family makes it differ from the q/k head (MiMo-V2,
328
333
  # DeepSeek). 0 means "same as the q/k head"; use :meth:`value_head_dim_for_layer`.
329
334
  v_head_dim: int = 0
@@ -914,12 +919,20 @@ class ArchSpec:
914
919
  def kv_heads_for_layer(self, layer: int) -> int:
915
920
  """How many key/value heads ``layer`` attends with.
916
921
 
917
- :attr:`n_kv_heads` for every family but Gemma-4-31B, whose full-attention layers carry 4 where
918
- its sliding ones carry 16. Prefer this anywhere ``k``, ``value`` or a k-side norm is reshaped
919
- per head: the model-wide number divides cleanly into the other layers' widths, so getting it
920
- wrong scrambles heads rather than raising. See :func:`facts.kv_heads_for_layer`.
922
+ :attr:`n_kv_heads` for every family but Gemma-4, whose full-attention layers carry 4 where its
923
+ sliding ones carry 16 on the 31B, and 2 against 8 on the 26B. Prefer this anywhere ``k``,
924
+ ``value`` or a k-side norm is reshaped per head: the model-wide number divides cleanly into
925
+ the other layers' widths, so getting it wrong scrambles heads rather than raising. See
926
+ :func:`facts.kv_heads_for_layer`.
921
927
  """
922
- return facts.kv_heads_for_layer(self.n_kv_heads, layer, self.per_layer_kv_heads)
928
+ return facts.kv_heads_for_layer(
929
+ self.n_kv_heads,
930
+ layer,
931
+ self.per_layer_kv_heads,
932
+ self.global_kv_heads,
933
+ self.quirks.hybrid_layer_types,
934
+ self.k_eq_v,
935
+ )
923
936
 
924
937
  def value_head_dim_for_layer(self, layer: int) -> int:
925
938
  """``layer``'s *value* head width -- what ``value`` and ``z`` are per head.
@@ -1160,6 +1173,8 @@ def resolve_arch(model: nn.Module, config: Any) -> ArchSpec:
1160
1173
  global_head_dim=model_facts.global_head_dim,
1161
1174
  per_layer_head_dim=model_facts.per_layer_head_dim,
1162
1175
  per_layer_kv_heads=model_facts.per_layer_kv_heads,
1176
+ global_kv_heads=model_facts.global_kv_heads,
1177
+ k_eq_v=model_facts.k_eq_v,
1163
1178
  v_head_dim=model_facts.v_head_dim,
1164
1179
  first_kv_shared_layer=model_facts.first_kv_shared_layer,
1165
1180
  layer_slots=layer_slots,
@@ -1072,7 +1072,15 @@ def logit_multiplier(cfg: Any) -> tuple[float | None, str]:
1072
1072
 
1073
1073
  # Four spellings of the routed-expert count, all in current use.
1074
1074
  _N_EXPERTS_FIELDS: tuple[str, ...] = ("num_local_experts", "num_experts", "n_routed_experts", "moe_num_experts")
1075
- _EXPERTS_PER_TOKEN_FIELDS: tuple[str, ...] = ("num_experts_per_tok", "experts_per_token", "moe_topk")
1075
+ # ``top_k_experts`` is Gemma-4's. Missing it did not merely lose a number: :func:`assert_routing_shapes`
1076
+ # guards its width check with ``and top_k``, so a zero here switched off the check that catches a
1077
+ # router tuple read in the wrong order -- on the newest MoE family, and silently.
1078
+ _EXPERTS_PER_TOKEN_FIELDS: tuple[str, ...] = (
1079
+ "num_experts_per_tok",
1080
+ "experts_per_token",
1081
+ "moe_topk",
1082
+ "top_k_experts",
1083
+ )
1076
1084
  _N_SHARED_EXPERTS_FIELDS: tuple[str, ...] = ("n_shared_experts", "num_shared_experts")
1077
1085
 
1078
1086
 
@@ -1228,6 +1236,27 @@ def block_types_name_the_feed_forward(cfg: Any) -> bool:
1228
1236
  return any(str(kind).lower() in SPARSE_MLP_LAYER_KINDS | DENSE_MLP_LAYER_KINDS for kind in kinds)
1229
1237
 
1230
1238
 
1239
+ def dense_mlp_beside_experts(cfg: Any) -> bool:
1240
+ """Whether a sparse layer *also* runs a dense MLP, whose output is added to the experts'.
1241
+
1242
+ Every other MoE family replaces the feed-forward with an expert bank, so a sparse layer has no
1243
+ dense MLP and no neuron basis. Gemma-4 does not: ``Gemma4TextDecoderLayer`` builds ``self.mlp`` on
1244
+ every layer, and where ``enable_moe_block`` is set it adds a routed branch *beside* it, combining
1245
+ them as ``hidden_states_1 + hidden_states_2`` -- two separately normed branches that both read the
1246
+ pre-feedforward residual.
1247
+
1248
+ Two consequences, and they pull in opposite directions, which is why this is its own fact. The
1249
+ parameter count of a sparse layer includes the dense MLP, so subtracting it (as the arithmetic
1250
+ does everywhere else) undercounts. And the dense neuron basis is real on a sparse layer here, so
1251
+ refusing ``mlp_act`` on one would be wrong.
1252
+
1253
+ Not the same thing as a shared expert: that lives inside the MoE block and is sized by
1254
+ ``moe_intermediate_size``, while this branch is the ordinary ``intermediate_size`` MLP that the
1255
+ checkpoint's dense siblings also carry.
1256
+ """
1257
+ return bool(config_attr(cfg, "enable_moe_block", False))
1258
+
1259
+
1231
1260
  def is_moe_layer(cfg: Any, layer: int) -> bool:
1232
1261
  """Whether ``layer``'s ``mlp`` is a sparse MoE block rather than a dense MLP.
1233
1262
 
@@ -1322,18 +1351,36 @@ def head_dim_for_layer(
1322
1351
  return head_dim if "sliding" in str(layer_types[layer]).lower() else global_head_dim
1323
1352
 
1324
1353
 
1325
- def kv_heads_for_layer(n_kv_heads: int, layer: int, per_layer: tuple[int, ...] = ()) -> int:
1354
+ def kv_heads_for_layer(
1355
+ n_kv_heads: int,
1356
+ layer: int,
1357
+ per_layer: tuple[int, ...] = (),
1358
+ global_kv_heads: int | None = None,
1359
+ layer_types: tuple[str, ...] | None = None,
1360
+ k_eq_v: bool = False,
1361
+ ) -> int:
1326
1362
  """How many key/value heads ``layer`` attends with.
1327
1363
 
1328
- One number for the whole model on every family but Gemma-4-31B, whose full-attention layers carry
1329
- 4 where its sliding ones carry 16 -- and whose config says so only through ``per_layer_config``,
1330
- so there is no older spelling to fall back to and ``per_layer`` empty means the model-wide value
1331
- is the answer. Getting it wrong is not a shape error (see :func:`effective_kv_heads`): the
1332
- reshape succeeds into a head count the layer does not have.
1364
+ One number for the whole model on every family but Gemma-4, whose full-attention layers carry 4
1365
+ where its sliding ones carry 16 on the 31B (2 against 8 on the 26B, 1 against 8 on the 12B).
1366
+ Getting it wrong is not a shape error (see :func:`effective_kv_heads`): the reshape succeeds into
1367
+ a head count the layer does not have.
1368
+
1369
+ Two spellings, as with :func:`head_dim_for_layer`. ``per_layer`` is the table a heterogeneous
1370
+ config states outright (transformers >= 5.15) and wins where it exists. ``global_kv_heads`` is the
1371
+ older ``num_global_key_value_heads``, and it applies only where the modeling code applies it:
1372
+ ``Gemma4TextAttention`` takes it when ``attention_k_eq_v and not is_sliding``, and the model-wide
1373
+ count otherwise. The ``k_eq_v`` gate is not decoration -- E2B and E4B set that flag false and
1374
+ carry ``num_global_key_value_heads: null``, so reading the field unconditionally would be wrong
1375
+ the moment a checkpoint states one without switching the flag on.
1333
1376
  """
1334
1377
  if per_layer and layer < len(per_layer):
1335
1378
  return per_layer[layer]
1336
- return n_kv_heads
1379
+ if not (k_eq_v and global_kv_heads):
1380
+ return n_kv_heads
1381
+ if not layer_types or layer >= len(layer_types):
1382
+ return n_kv_heads
1383
+ return n_kv_heads if "sliding" in str(layer_types[layer]).lower() else global_kv_heads
1337
1384
 
1338
1385
 
1339
1386
  # A scalar the attention module multiplies its value vectors by *after* the projection, so the tensor
@@ -1747,6 +1794,15 @@ class ModelFacts:
1747
1794
  # prefer these; see :func:`per_layer_ints`.
1748
1795
  per_layer_head_dim: tuple[int, ...] = ()
1749
1796
  per_layer_kv_heads: tuple[int, ...] = ()
1797
+ # Gemma-4's kv-head count for its ``full_attention`` layers, which it states as
1798
+ # ``num_global_key_value_heads`` and applies only when ``attention_k_eq_v`` is on. The older
1799
+ # spelling of what ``per_layer_kv_heads`` carries on transformers >= 5.15; ask
1800
+ # :meth:`kv_heads_for_layer`.
1801
+ global_kv_heads: int | None = None
1802
+ # Gemma-4's ``attention_k_eq_v``: on its ``full_attention`` layers the value tensor *is* the key
1803
+ # projection's output (differently normed and un-RoPE'd), and those layers are built with
1804
+ # ``v_proj = None``. True on 26B/31B/12B, false on E2B/E4B.
1805
+ k_eq_v: bool = False
1750
1806
  # The width of one value head, which differs from ``head_dim`` on MiMo-V2 and the DeepSeek MLA
1751
1807
  # families. See :func:`value_head_dim`; ``value`` and ``z`` are this wide per head, not ``head_dim``.
1752
1808
  v_head_dim: int = 0
@@ -1759,6 +1815,9 @@ class ModelFacts:
1759
1815
  n_experts: int = 0
1760
1816
  experts_per_token: int = 0
1761
1817
  n_shared_experts: int = 0
1818
+ # Gemma-4: a sparse layer keeps its dense MLP and adds the routed branch beside it, so the two
1819
+ # coexist rather than the experts replacing the MLP. See :func:`dense_mlp_beside_experts`.
1820
+ dense_mlp_beside_experts: bool = False
1762
1821
  # Layers whose ``mlp`` is a sparse block. Precomputed here because the branch needs config
1763
1822
  # fields that the vLLM client does not carry across the process boundary.
1764
1823
  moe_layers: tuple[int, ...] = ()
@@ -1842,8 +1901,10 @@ class ModelFacts:
1842
1901
  return head_dim_for_layer(self.head_dim, self.global_head_dim, self.layer_types, layer, self.per_layer_head_dim)
1843
1902
 
1844
1903
  def kv_heads_for_layer(self, layer: int) -> int:
1845
- """``layer``'s kv-head count. Prefer this to :attr:`n_kv_heads`, which is wrong on Gemma-4-31B."""
1846
- return kv_heads_for_layer(self.n_kv_heads, layer, self.per_layer_kv_heads)
1904
+ """``layer``'s kv-head count. Prefer this to :attr:`n_kv_heads`, which is wrong on Gemma-4."""
1905
+ return kv_heads_for_layer(
1906
+ self.n_kv_heads, layer, self.per_layer_kv_heads, self.global_kv_heads, self.layer_types, self.k_eq_v
1907
+ )
1847
1908
 
1848
1909
  def value_head_dim_for_layer(self, layer: int) -> int:
1849
1910
  """``layer``'s *value* head width, for reshaping ``value`` and ``z``. See :func:`value_head_dim`.
@@ -2198,11 +2259,14 @@ def resolve_facts(config: Any, *, n_layers_fallback: int | None = None) -> Model
2198
2259
  global_head_dim=_first_int(cfg, ("global_head_dim",)) or None,
2199
2260
  per_layer_head_dim=per_layer_ints(cfg, "head_dim", n_layers),
2200
2261
  per_layer_kv_heads=per_layer_ints(cfg, "num_key_value_heads", n_layers),
2262
+ global_kv_heads=_first_int(cfg, ("num_global_key_value_heads",)) or None,
2263
+ k_eq_v=bool(config_attr(cfg, "attention_k_eq_v", False)),
2201
2264
  v_head_dim=value_head_dim(cfg, head_dim),
2202
2265
  first_kv_shared_layer=first_kv_shared_layer(cfg, n_layers),
2203
2266
  n_experts=n_experts(cfg),
2204
2267
  experts_per_token=_first_int(cfg, _EXPERTS_PER_TOKEN_FIELDS) or 0,
2205
2268
  n_shared_experts=_first_int(cfg, _N_SHARED_EXPERTS_FIELDS) or 0,
2269
+ dense_mlp_beside_experts=dense_mlp_beside_experts(cfg),
2206
2270
  moe_layers=tuple(layer for layer in range(n_layers) if is_moe_layer(cfg, layer)),
2207
2271
  logit_multiplier=multiplier,
2208
2272
  logit_multiplier_source=multiplier_source,
@@ -0,0 +1,90 @@
1
+ """Give a notebook kernel's stdout the file descriptor vLLM's engine start needs.
2
+
3
+ vLLM silences C-level output around ``torch.distributed.new_group`` by dup'ing over a
4
+ descriptor (``suppress_stdout`` in ``vllm/utils/system_utils.py``), and its EngineCore
5
+ child is forked, so the child inherits whichever ``sys.stdout`` the parent had. Under
6
+ Jupyter that is ``ipykernel.iostream.OutStream``, which writes through a ZMQ socket and
7
+ raises ``io.UnsupportedOperation`` from ``fileno()``. The child then dies before it loads
8
+ anything, and what reaches the caller is vLLM's ``Engine core initialization failed. See
9
+ root cause above.`` -- above being a traceback in another process, about a descriptor.
10
+
11
+ A descriptor is what that stream is supposed to hand back: ipykernel answers ``fileno()``
12
+ with ``_original_stdstream_copy``, a dup of the real stdout it took over, whenever the
13
+ kernel was started capturing the low-level ones. This supplies the one a kernel that was
14
+ not started that way -- Colab's -- did not keep. Colab is the case that matters, because
15
+ this repo's own notebook templates run there.
16
+
17
+ The descriptor stays on the stream rather than being restored after the engine is built.
18
+ The fork is not the only caller: vLLM suppresses stdout the same way around its stateless
19
+ process groups, from whichever process reaches one.
20
+
21
+ Forcing ``spawn`` would also give the child a stdout of its own, and is what
22
+ ``VLLM_WORKER_MULTIPROC_METHOD`` is for. It is not what this does, because a spawned
23
+ child writes to the kernel's real stdout rather than to the cell -- which takes the engine
24
+ logs, and the root cause behind that message, out of the notebook with it.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import os
31
+ import sys
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ def _answers_fileno(stream: object) -> bool:
37
+ """Whether ``stream.fileno()`` returns rather than raises.
38
+
39
+ ``io.UnsupportedOperation`` is both an ``OSError`` and a ``ValueError``, which is what
40
+ a kernel stream raises; a detached or closed one raises ``ValueError`` on its own.
41
+ """
42
+ try:
43
+ stream.fileno() # type: ignore[attr-defined]
44
+ except (AttributeError, OSError, ValueError):
45
+ return False
46
+ return True
47
+
48
+
49
+ def _process_stdout_fd() -> int | None:
50
+ """The descriptor this process's stdout is, or None when it has none.
51
+
52
+ ``sys.__stdout__`` is the stream Python opened at startup, which Jupyter leaves alone
53
+ when it replaces ``sys.stdout``, so it is the first place to ask. Fd 1 covers an
54
+ embedding that replaced both. Either is confirmed with ``fstat`` before being handed
55
+ out, since a daemonized process may have closed it.
56
+ """
57
+ original = sys.__stdout__
58
+ fd = original.fileno() if original is not None and _answers_fileno(original) else 1
59
+ try:
60
+ os.fstat(fd)
61
+ except OSError:
62
+ return None
63
+ return fd
64
+
65
+
66
+ def ensure_stdout_descriptor() -> bool:
67
+ """Give ``sys.stdout`` a ``fileno()`` if it has none, and say whether it needed one.
68
+
69
+ Idempotent, and a no-op outside a notebook. A stream that already answers is left
70
+ alone, and so is a process whose stdout is closed: there is no descriptor to offer
71
+ there, and vLLM's refusal is then the honest outcome rather than one to work around.
72
+ """
73
+ stream = sys.stdout
74
+ if stream is None or _answers_fileno(stream):
75
+ return False
76
+ fd = _process_stdout_fd()
77
+ if fd is None:
78
+ return False
79
+ try:
80
+ stream.fileno = lambda: fd # type: ignore[method-assign]
81
+ except (AttributeError, TypeError):
82
+ # A stream that takes no new attribute, which a C-level or slotted one does not.
83
+ # Nothing is lost: this is the refusal vLLM was about to raise anyway.
84
+ return False
85
+ logger.info(
86
+ "%s has no file descriptor, which vLLM's engine start requires; answering fileno() with fd %d.",
87
+ type(stream).__name__,
88
+ fd,
89
+ )
90
+ return True
@@ -30,6 +30,7 @@ from interp_engine import facts
30
30
  from interp_engine._loop import refuse_foreign_loop
31
31
  from interp_engine.address import Address, format_address, to_address
32
32
  from interp_engine.autograd_support import GradSupport, vllm_grad_support
33
+ from interp_engine.notebook_stdout import ensure_stdout_descriptor
33
34
  from interp_engine.points import d_model_wide, hyper_connection_names, refusal_reasons
34
35
  from interp_engine.points import steer_refusal_reason as points_steer_refusal
35
36
  from interp_engine.residual_basis import ResidualBasis, vllm_residual_basis
@@ -314,6 +315,11 @@ def read_attn_dims(hf_model_id: str, trust_remote_code: bool = True) -> dict[str
314
315
  # Empty on a config that describes one shape for the whole model.
315
316
  "per_layer_head_dim": model_facts.per_layer_head_dim,
316
317
  "per_layer_kv_heads": model_facts.per_layer_kv_heads,
318
+ # The older spelling of that kv-head count (`num_global_key_value_heads`), and the flag
319
+ # Gemma-4 gates it on. Carried so a transformers below 5.15 -- which states no per-layer
320
+ # table -- still gets the wide layers' count right rather than the sliding one's.
321
+ "global_kv_heads": model_facts.global_kv_heads,
322
+ "k_eq_v": model_facts.k_eq_v,
317
323
  # From here on a layer reuses an earlier layer's keys/values and has no v_proj to hook, so
318
324
  # `value`/DFA is unavailable there (Gemma-4). None when every layer projects its own.
319
325
  "first_kv_shared_layer": model_facts.first_kv_shared_layer,
@@ -406,7 +412,12 @@ def kv_heads_for_layer(dims: dict[str, Any], layer: int) -> int:
406
412
  disagreement between the two is worth saying out loud.
407
413
  """
408
414
  return facts.kv_heads_for_layer(
409
- int(dims.get("n_kv_heads") or 0), layer, tuple(dims.get("per_layer_kv_heads") or ())
415
+ int(dims.get("n_kv_heads") or 0),
416
+ layer,
417
+ tuple(dims.get("per_layer_kv_heads") or ()),
418
+ dims.get("global_kv_heads"),
419
+ tuple(dims.get("layer_types") or ()),
420
+ bool(dims.get("k_eq_v")),
410
421
  )
411
422
 
412
423
 
@@ -550,7 +561,10 @@ def recompute_attn_from_payloads(payloads, layers, dims, tensor_parallel_size: i
550
561
  # one is not a claim about this layer -- it disagrees with Gemma-4's wide layers by design, and
551
562
  # on an MLA or tensor-parallel capture it describes something other than the width in hand --
552
563
  # so passing it as `expected` would turn a working recompute into a raise.
553
- stated_kv = kv_heads_for_layer(dims, int(layer)) if dims.get("per_layer_kv_heads") else None
564
+ states_per_layer_kv = bool(dims.get("per_layer_kv_heads")) or bool(
565
+ dims.get("k_eq_v") and dims.get("global_kv_heads")
566
+ )
567
+ stated_kv = kv_heads_for_layer(dims, int(layer)) if states_per_layer_kv else None
554
568
  n_kv_heads = _heads_in(k, head_dim, "k", int(layer), expected=stated_kv)
555
569
  scores = recompute_attn_scores(
556
570
  q,
@@ -1213,6 +1227,9 @@ class VLLMModel:
1213
1227
  tuple(getattr(self, "_static_reads", ())),
1214
1228
  tuple(getattr(self, "_static_writes", ())),
1215
1229
  )
1230
+ # The line below forks a child that suppresses stdout by descriptor, which a
1231
+ # notebook kernel's stdout does not have. See `notebook_stdout`.
1232
+ ensure_stdout_descriptor()
1216
1233
  self.engine = AsyncLLM.from_engine_args(AsyncEngineArgs(**self._engine_kwargs))
1217
1234
  self._engine_loop = asyncio.get_running_loop()
1218
1235
  return self.engine
@@ -446,9 +446,12 @@ def _config_weight_bytes(config: Any) -> int:
446
446
  routed_mlp = f.n_experts * 3 * f.d_model * moe_inter
447
447
  shared_mlp = f.n_shared_experts * 3 * f.d_model * moe_inter
448
448
  n_sparse = len(f.moe_layers) if f.n_experts else 0
449
+ # Gemma-4's sparse layers keep their dense MLP beside the experts, so it is paid on every layer.
450
+ # It stays at the model dtype either way -- it is not part of the quantized routed bank.
451
+ n_dense = f.n_layers if f.dense_mlp_beside_experts else f.n_layers - n_sparse
449
452
  embeddings = f.vocab_size * f.d_model * (1 if f.tied_embeddings else 2)
450
453
  stored = _storage_dtype_bytes(config)
451
- at_model_dtype = f.n_layers * attn + (f.n_layers - n_sparse) * dense_mlp + n_sparse * shared_mlp + embeddings
454
+ at_model_dtype = f.n_layers * attn + n_dense * dense_mlp + n_sparse * shared_mlp + embeddings
452
455
  return int(at_model_dtype * stored + n_sparse * routed_mlp * _expert_dtype_bytes(config, stored))
453
456
 
454
457
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "interp-engine"
3
- version = "1.3.3"
3
+ version = "1.3.5"
4
4
  description = "A fast, standardized, and easy to use interpretability engine."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -0,0 +1,132 @@
1
+ """Unit tests for the stdout descriptor a notebook kernel does not provide.
2
+
3
+ No vLLM and no GPU: what is under test is a property of ``sys.stdout``, and the stream
4
+ that has that property in the field -- ipykernel's ``OutStream`` -- is stood in for by a
5
+ class which raises from ``fileno()`` the same way. The engine-shaped half is a vLLM engine
6
+ started under such a stream, which fails in the EngineCore child rather than here.
7
+
8
+ ``sys.stdout`` is replaced **inside each test body** rather than by a fixture. pytest
9
+ captures at the descriptor level and reassigns ``sys.stdout`` every time it resumes
10
+ capture, including on the way from setup into the call phase -- so a patch applied in a
11
+ fixture is gone by the time the test runs, and the test passes or fails on the runner's
12
+ stream instead of the one it meant to use.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import io
18
+ import os
19
+ import sys
20
+ from typing import IO, Any
21
+
22
+ import pytest
23
+
24
+ from interp_engine.notebook_stdout import ensure_stdout_descriptor
25
+
26
+
27
+ class KernelStdout:
28
+ """``ipykernel.iostream.OutStream``, as far as this matters.
29
+
30
+ Writes go somewhere that is not a file -- a ZMQ socket, in the real one -- so there is
31
+ no descriptor to answer with, which is the whole of the failure being reproduced.
32
+ """
33
+
34
+ def __init__(self) -> None:
35
+ self.written: list[str] = []
36
+
37
+ def write(self, text: str) -> int:
38
+ self.written.append(text)
39
+ return len(text)
40
+
41
+ def flush(self) -> None:
42
+ pass
43
+
44
+ def fileno(self) -> int:
45
+ raise io.UnsupportedOperation("fileno")
46
+
47
+
48
+ class SlottedStdout:
49
+ """A stream that takes no new attribute, as a C-level one does not."""
50
+
51
+ __slots__ = ()
52
+
53
+ def fileno(self) -> int:
54
+ raise io.UnsupportedOperation("fileno")
55
+
56
+
57
+ def _as_stdout(monkeypatch: pytest.MonkeyPatch, stream: object) -> None:
58
+ """Make ``stream`` this test's ``sys.stdout``. Call from the test body; see above."""
59
+ monkeypatch.setattr(sys, "stdout", stream)
60
+
61
+
62
+ def test_a_kernel_stream_gains_a_descriptor_the_os_accepts(monkeypatch: pytest.MonkeyPatch) -> None:
63
+ """The point of the exercise: ``fileno()`` answers, and with a live descriptor.
64
+
65
+ ``fstat`` is the assertion rather than a particular number, because what vLLM does
66
+ with the answer is ``os.dup``, which fails on anything ``fstat`` would reject too.
67
+ """
68
+ _as_stdout(monkeypatch, KernelStdout())
69
+ assert ensure_stdout_descriptor() is True
70
+ os.fstat(sys.stdout.fileno())
71
+
72
+
73
+ def test_writes_still_go_where_they_did(monkeypatch: pytest.MonkeyPatch) -> None:
74
+ """A descriptor is added; nothing is redirected. The cell keeps its output."""
75
+ stream = KernelStdout()
76
+ _as_stdout(monkeypatch, stream)
77
+ ensure_stdout_descriptor()
78
+ print("to the notebook")
79
+ assert "to the notebook" in "".join(stream.written)
80
+
81
+
82
+ def test_it_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None:
83
+ """Every engine build calls this, and only the first one has anything to do."""
84
+ _as_stdout(monkeypatch, KernelStdout())
85
+ assert ensure_stdout_descriptor() is True
86
+ assert ensure_stdout_descriptor() is False
87
+
88
+
89
+ def test_a_real_stream_is_left_alone(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
90
+ """A script's stdout already answers, so this must not touch it.
91
+
92
+ The second assertion is what says *untouched* rather than merely still working: a
93
+ descriptor of our own, put on the instance, would satisfy the first one too.
94
+ """
95
+ with open(tmp_path / "out", "w") as real:
96
+ _as_stdout(monkeypatch, real)
97
+ assert ensure_stdout_descriptor() is False
98
+ assert "fileno" not in getattr(real, "__dict__", {})
99
+
100
+
101
+ def test_a_stream_that_takes_no_attribute_is_refused(monkeypatch: pytest.MonkeyPatch) -> None:
102
+ """Refused rather than raised: vLLM's own error is a better one than ours here."""
103
+ _as_stdout(monkeypatch, SlottedStdout())
104
+ assert ensure_stdout_descriptor() is False
105
+
106
+
107
+ def test_a_closed_process_stdout_offers_nothing(monkeypatch: pytest.MonkeyPatch) -> None:
108
+ """With none to lend, the answer is no rather than a number that fails later.
109
+
110
+ This is the daemonized process, whose stdout was closed before Python started. Both
111
+ sources are taken away: ``sys.__stdout__``, and the fd 1 fallback that stands in for
112
+ it, which is open in a test runner and would otherwise be handed out.
113
+ """
114
+ _as_stdout(monkeypatch, KernelStdout())
115
+ monkeypatch.setattr(sys, "__stdout__", None)
116
+ monkeypatch.setattr("interp_engine.notebook_stdout.os.fstat", _raise_ebadf)
117
+ assert ensure_stdout_descriptor() is False
118
+
119
+
120
+ def _raise_ebadf(fd: int) -> os.stat_result:
121
+ raise OSError(9, "Bad file descriptor")
122
+
123
+
124
+ def test_the_stand_in_matches_ipykernel() -> None:
125
+ """The premise, asserted where it can be: ``fileno()`` raises what is caught for.
126
+
127
+ ``io.UnsupportedOperation`` is both an ``OSError`` and a ``ValueError``, which is why
128
+ the module catches those two rather than importing ipykernel to name the class.
129
+ """
130
+ stream: IO[Any] = KernelStdout() # type: ignore[assignment]
131
+ with pytest.raises((OSError, ValueError)):
132
+ stream.fileno()
File without changes
File without changes
File without changes