interp-engine 1.11.0__tar.gz → 1.11.2__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 (183) hide show
  1. {interp_engine-1.11.0 → interp_engine-1.11.2}/PKG-INFO +2 -2
  2. {interp_engine-1.11.0 → interp_engine-1.11.2}/docs/ENGINE_HOOK_MAPPINGS.md +31 -4
  3. {interp_engine-1.11.0 → interp_engine-1.11.2}/docs/USAGE.md +38 -1
  4. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/__init__.py +19 -0
  5. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/mappers.py +94 -10
  6. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/model.py +61 -4
  7. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/protocol.py +42 -3
  8. interp_engine-1.11.2/interp_engine/sampling.py +264 -0
  9. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/steer.py +30 -5
  10. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/sync.py +44 -4
  11. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_backend.py +64 -12
  12. {interp_engine-1.11.0 → interp_engine-1.11.2}/pyproject.toml +4 -3
  13. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/conftest.py +15 -0
  14. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_mappers.py +104 -0
  15. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_parity_gpt2.py +120 -0
  16. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_points_registry.py +1 -1
  17. interp_engine-1.11.2/tests/test_sampling.py +226 -0
  18. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vllm_kv_isolation.py +4 -0
  19. {interp_engine-1.11.0 → interp_engine-1.11.2}/.gitignore +0 -0
  20. {interp_engine-1.11.0 → interp_engine-1.11.2}/LICENSE +0 -0
  21. {interp_engine-1.11.0 → interp_engine-1.11.2}/README.md +0 -0
  22. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/README.md +0 -0
  23. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/__init__.py +0 -0
  24. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/bench_spec.py +0 -0
  25. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/cells.py +0 -0
  26. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/probe.py +0 -0
  27. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/probe_lens_stream.py +0 -0
  28. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/publish.py +0 -0
  29. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/report_bench.py +0 -0
  30. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/deepseek-v4-flash-0731__eager.json +0 -0
  31. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/deepseek-v4-flash-0731__vllm-cudagraph.json +0 -0
  32. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/deepseek-v4-flash-0731__vllm-static.json +0 -0
  33. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/deepseek-v4-flash-0731__vllm.json +0 -0
  34. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/gemma-2-2b__eager.json +0 -0
  35. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/gemma-2-2b__vllm-cudagraph.json +0 -0
  36. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/gemma-2-2b__vllm-static.json +0 -0
  37. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/gemma-2-2b__vllm.json +0 -0
  38. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/llama-3.1-8b__eager.json +0 -0
  39. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/llama-3.1-8b__vllm-cudagraph.json +0 -0
  40. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/llama-3.1-8b__vllm-static.json +0 -0
  41. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/llama-3.1-8b__vllm.json +0 -0
  42. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/qwen3-4b__eager.json +0 -0
  43. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/qwen3-4b__vllm-cudagraph.json +0 -0
  44. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/qwen3-4b__vllm-static.json +0 -0
  45. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/qwen3-4b__vllm.json +0 -0
  46. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/qwen3.8-27b__eager.json +0 -0
  47. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/qwen3.8-27b__vllm-cudagraph.json +0 -0
  48. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/qwen3.8-27b__vllm-static.json +0 -0
  49. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results/qwen3.8-27b__vllm.json +0 -0
  50. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/results-latest.md +0 -0
  51. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/run_all.sh +0 -0
  52. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/run_bench.py +0 -0
  53. {interp_engine-1.11.0 → interp_engine-1.11.2}/benchmarks/workloads.py +0 -0
  54. {interp_engine-1.11.0 → interp_engine-1.11.2}/docs/AGENT_INTEGRATION.md +0 -0
  55. {interp_engine-1.11.0 → interp_engine-1.11.2}/docs/ARCHITECTURE_QUIRKS.md +0 -0
  56. {interp_engine-1.11.0 → interp_engine-1.11.2}/docs/COMPATIBILITY.md +0 -0
  57. {interp_engine-1.11.0 → interp_engine-1.11.2}/docs/GRADIENTS.md +0 -0
  58. {interp_engine-1.11.0 → interp_engine-1.11.2}/docs/INTERNALS.md +0 -0
  59. {interp_engine-1.11.0 → interp_engine-1.11.2}/docs/PERFORMANCE.md +0 -0
  60. {interp_engine-1.11.0 → interp_engine-1.11.2}/docs/PORTING.md +0 -0
  61. {interp_engine-1.11.0 → interp_engine-1.11.2}/docs/README.md +0 -0
  62. {interp_engine-1.11.0 → interp_engine-1.11.2}/docs/SUPPORTED_POINTS.md +0 -0
  63. {interp_engine-1.11.0 → interp_engine-1.11.2}/docs/VALIDATING_A_NEW_MODEL.md +0 -0
  64. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/_loop.py +0 -0
  65. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/address.py +0 -0
  66. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/arch.py +0 -0
  67. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/attn_config.py +0 -0
  68. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/attn_scores.py +0 -0
  69. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/autograd_support.py +0 -0
  70. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/capture.py +0 -0
  71. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/chat_compose.py +0 -0
  72. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/chat_conventions.py +0 -0
  73. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/chat_formatters.py +0 -0
  74. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/cuda_preflight.py +0 -0
  75. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/dispatch.py +0 -0
  76. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/facts.py +0 -0
  77. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/hooks.py +0 -0
  78. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/lens.py +0 -0
  79. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/load.py +0 -0
  80. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/memory.py +0 -0
  81. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/moe_routing.py +0 -0
  82. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/notebook_stdout.py +0 -0
  83. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/points.py +0 -0
  84. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/residual_basis.py +0 -0
  85. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/select.py +0 -0
  86. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/steer_specs.py +0 -0
  87. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/tokenize.py +0 -0
  88. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/__init__.py +0 -0
  89. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/_demux.py +0 -0
  90. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/_hooks.py +0 -0
  91. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/_payload.py +0 -0
  92. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/_tp.py +0 -0
  93. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/_tree.py +0 -0
  94. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/attn.py +0 -0
  95. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/capture.py +0 -0
  96. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/graphs.py +0 -0
  97. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/lens/__init__.py +0 -0
  98. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/lens/intervene.py +0 -0
  99. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/lens/readout.py +0 -0
  100. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/lens/unembed.py +0 -0
  101. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/mhc.py +0 -0
  102. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/native.py +0 -0
  103. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/requests.py +0 -0
  104. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/static.py +0 -0
  105. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_capture/steering.py +0 -0
  106. {interp_engine-1.11.0 → interp_engine-1.11.2}/interp_engine/vllm_plugin.py +0 -0
  107. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/harness.py +0 -0
  108. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/model_expectations.yaml +0 -0
  109. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/synthetic_families.py +0 -0
  110. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_address.py +0 -0
  111. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_attn_config_tripwire.py +0 -0
  112. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_attn_probs_indexing.py +0 -0
  113. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_attn_scores.py +0 -0
  114. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_attn_z_gqa.py +0 -0
  115. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_autograd_support.py +0 -0
  116. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_bench_workloads.py +0 -0
  117. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_capability_refusals.py +0 -0
  118. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_capture_addressing.py +0 -0
  119. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_chat_compose.py +0 -0
  120. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_chat_formatters.py +0 -0
  121. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_chat_templates.py +0 -0
  122. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_core.py +0 -0
  123. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_cuda_preflight.py +0 -0
  124. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_doc_code_fences.py +0 -0
  125. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_eager_autograd.py +0 -0
  126. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_facts.py +0 -0
  127. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_family_points.py +0 -0
  128. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_gated_attn_out.py +0 -0
  129. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_gpu_sizer.py +0 -0
  130. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_head_contributions.py +0 -0
  131. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_hook_call_conventions.py +0 -0
  132. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_injected_system_spans.py +0 -0
  133. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_layer_kinds.py +0 -0
  134. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_load.py +0 -0
  135. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_logit_transform.py +0 -0
  136. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_memory.py +0 -0
  137. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_mlp_internals.py +0 -0
  138. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_model_expectations.py +0 -0
  139. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_moe.py +0 -0
  140. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_multigpu.py +0 -0
  141. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_multimodal_arch.py +0 -0
  142. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_new_models_gpu.py +0 -0
  143. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_no_chat_template.py +0 -0
  144. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_normalized_hook.py +0 -0
  145. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_notebook_stdout.py +0 -0
  146. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_packaging.py +0 -0
  147. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_per_layer_attn_dims.py +0 -0
  148. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_protocol.py +0 -0
  149. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_published_benchmarks.py +0 -0
  150. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_qk_norm.py +0 -0
  151. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_qkv_layout.py +0 -0
  152. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_reasoning_spans.py +0 -0
  153. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_release.py +0 -0
  154. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_resid_mid.py +0 -0
  155. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_residual_basis.py +0 -0
  156. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_sandwich_norms.py +0 -0
  157. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_select.py +0 -0
  158. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_sliding_window_attn.py +0 -0
  159. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_small_models_gpu.py +0 -0
  160. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_static_dsv4_gpu.py +0 -0
  161. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_static_parity_gpu.py +0 -0
  162. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_static_set.py +0 -0
  163. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_static_warmup.py +0 -0
  164. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_steer_context.py +0 -0
  165. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_steer_math_parity.py +0 -0
  166. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_sync_loop.py +0 -0
  167. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_sync_parity.py +0 -0
  168. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_unified_free_functions.py +0 -0
  169. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_unresolved_families.py +0 -0
  170. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vllm_capture_gpu.py +0 -0
  171. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vllm_capture_scales.py +0 -0
  172. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vllm_engine_loop.py +0 -0
  173. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vllm_graph_path.py +0 -0
  174. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vllm_graphs_on_gpu.py +0 -0
  175. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vllm_hook_availability.py +0 -0
  176. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vllm_hyper_connections.py +0 -0
  177. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vllm_new_points.py +0 -0
  178. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vllm_only_families.py +0 -0
  179. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vllm_plugin.py +0 -0
  180. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vllm_wire_grammar.py +0 -0
  181. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_vocabulary_boundary.py +0 -0
  182. {interp_engine-1.11.0 → interp_engine-1.11.2}/tests/test_worker_lens_capture_readout.py +0 -0
  183. {interp_engine-1.11.0 → interp_engine-1.11.2}/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.11.0
3
+ Version: 1.11.2
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
@@ -21,7 +21,7 @@ Requires-Dist: pytest<9,>=8.3.1; extra == 'dev'
21
21
  Requires-Dist: pyyaml>=6; extra == 'dev'
22
22
  Requires-Dist: ruff<0.17,>=0.16.2; extra == 'dev'
23
23
  Provides-Extra: parity
24
- Requires-Dist: transformer-lens>=3.0; extra == 'parity'
24
+ Requires-Dist: transformer-lens<4,>=3.0; extra == 'parity'
25
25
  Provides-Extra: quant
26
26
  Requires-Dist: accelerate>=1.0; extra == 'quant'
27
27
  Requires-Dist: kernels<0.17.0,>=0.15.2; extra == 'quant'
@@ -24,7 +24,7 @@ that capture is conditional. Each links to the note under the table.
24
24
  | -------------------------------------------- | --------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
25
25
  | `embeddings` | `cache["embeddings"]` | `hook_embed` | `token_embeddings` | [three tensors, three conventions](#embeddings-three-different-tensors) |
26
26
  | `resid_pre` | `cache["resid_pre", 5]` | `blocks.5.hook_resid_pre` | `layers_input[5]` | |
27
- | `attn_in` | `cache["attn_in", 5]` | `blocks.5.hook_attn_in` (or `attn.hook_in`) | `attentions_input[5]` | [what the sublayer was handed, normed or not](#attn_in-is-the-attention-modules-input-not-a-norms-output) |
27
+ | `attn_in` | `cache["attn_in", 5]` | `blocks.5.attn.hook_in` (**not** `hook_attn_in`) | `attentions_input[5]` | [the norm's output; the block-level hook is its input](#the-block-level-hook_attn_in-and-hook_mlp_in-are-the-norms-input) |
28
28
  | `q_norm_in` / `k_norm_in` (QK-norm input) | `cache["q_norm_in", 5]` | `blocks.5.attn.q_norm.hook_in` (bridge only) | — | [shape is family-dependent](#qk-norm-shape-is-family-dependent) |
29
29
  | `q_norm_out` / `k_norm_out` (QK-norm output) | `cache["q_norm_out", 5]` | `blocks.5.attn.q_norm.hook_out` (bridge only) | — | [shape is family-dependent](#qk-norm-shape-is-family-dependent) |
30
30
  | `value` | `cache["value", 5]` | `blocks.5.attn.hook_v` | — | [KV heads, and flattened](#addressing-and-shape-which-do-not-translate-for-free) |
@@ -35,7 +35,7 @@ that capture is conditional. Each links to the note under the table.
35
35
  | `attn_out` (raw module output) | `cache["attn_out", 5]` | `blocks.5.attn.hook_out` | `attentions_output[5]` | [raw output, not the contribution](#raw-output-versus-residual-contribution) |
36
36
  | `attn_out_post` (residual contribution) | `cache["attn_out_post", 5]` | `blocks.5.hook_attn_out` | — | [same TL name, different tensor](#raw-output-versus-residual-contribution) |
37
37
  | `resid_mid` | `cache["resid_mid", 5]` (writes refused under vLLM) | `blocks.5.hook_resid_mid` | _no accessor_: the pre-MLP norm's `.input`, under its HF name | [captured, not reconstructed](#resid_mid-is-captured-not-reconstructed) |
38
- | `mlp_in` | `cache["mlp_in", 5]` | `blocks.5.mlp.hook_in` (or `hook_mlp_in`) | `mlps_input[5]` | [not `ln2.hook_normalized`, which is pre-gain](#a-norms-hook_normalized-is-not-the-sublayer-input) |
38
+ | `mlp_in` | `cache["mlp_in", 5]` | `blocks.5.mlp.hook_in` (**not** `hook_mlp_in`) | `mlps_input[5]` | [not `hook_mlp_in`, which is pre-norm](#the-block-level-hook_attn_in-and-hook_mlp_in-are-the-norms-input) |
39
39
  | `mlp_pre` (pre-activation) | `cache["mlp_pre", 5]` `*` | `blocks.5.mlp.hook_pre` | — | [the neuron basis, and `up_proj` is ambiguous](#the-mlp-internal-points-are-the-neuron-basis) |
40
40
  | `mlp_pre_linear` (gated MLPs only) | `cache["mlp_pre_linear", 5]` `*` | `blocks.5.mlp.hook_pre_linear` | — | [refuses on a plain MLP](#the-mlp-internal-points-are-the-neuron-basis) |
41
41
  | `mlp_act` (post-activation neurons) | `cache["mlp_act", 5]` | `blocks.5.mlp.hook_post` | — | [`d_mlp` wide; refuses on sparse layers](#the-mlp-internal-points-are-the-neuron-basis) |
@@ -45,7 +45,7 @@ that capture is conditional. Each links to the note under the table.
45
45
  | `mlp_out` (raw module output) | `cache["mlp_out", 5]` | `blocks.5.mlp.hook_out` | `mlps_output[5]` | [raw output, not the contribution](#raw-output-versus-residual-contribution) |
46
46
  | `mlp_out_post` (residual contribution) | `cache["mlp_out_post", 5]` | `blocks.5.hook_mlp_out` | — | [same TL name, different tensor](#raw-output-versus-residual-contribution) |
47
47
  | `resid_post` | `cache["resid_post", 5]` | `blocks.5.hook_resid_post` | `layers_output[5]` | |
48
- | `final_norm` | `cache["final_norm"]` | `ln_final.hook_normalized` | `ln_final.output` | |
48
+ | `final_norm` | `cache["final_norm"]` | `unembed.hook_in` (TransformerLens 3) | `ln_final.output` | |
49
49
  | `lm_head` | `cache["lm_head"]` `*` | _(logits are returned, not hooked)_ | `logits` | [a bare unembed, which vLLM does not offer](#the-unembed-and-what-vllm-returns) |
50
50
 
51
51
  A hyper-connection trunk adds seven more points, which are conditional on the family and so are
@@ -147,6 +147,33 @@ Scope's transcoders declare it as their SAELens `hook_name`, circuit-tracer's as
147
147
  Substituting `mlp_in` reads them off the wrong activations. Same reasoning one level down for
148
148
  `q_norm.hook_normalized`, in the table at the end.
149
149
 
150
+ ### The block-level `hook_attn_in` and `hook_mlp_in` are the norm's input
151
+
152
+ TransformerLens has two names each for the sublayer inputs, and they are a whole normalization apart.
153
+ `blocks.5.mlp.hook_in` is the MLP module's argument: `ln2`'s output, gain included, which is our
154
+ `mlp_in`. `blocks.5.hook_mlp_in` is the block-level hook, and `TransformerBlock.forward` fires it on
155
+ `resid_mid` and passes the result _into_ `ln2` — so it is the norm's **input**, `resid_mid` under
156
+ another name. `hook_attn_in` is the same story on `resid_pre`, broadcast per head to
157
+ `[batch, pos, n_heads, d_model]`. Measured on the HF modules with neither library in the loop: on
158
+ gemma-3-1b-it the norm's input and output share cosine 0.175 at layer 5 and differ in norm by 231x,
159
+ past 3000x deeper in the stack; on gpt2 the cosine is 0.75 and the norms differ by 27–86x.
160
+
161
+ `tlens_hook_to_point` therefore **refuses** `hook_attn_in` / `hook_mlp_in` rather than mapping them to
162
+ either side. Mapping them to `attn_in` / `mlp_in` was the bug (issue #24): a caller got a plausible
163
+ tensor of the right shape, and an SAE or steering vector trained on TransformerLens' tensor was read
164
+ off the wrong one. Mapping them to `resid_pre` / `resid_mid` would be faithful to what the hook carries
165
+ and still wrong to do silently, because the caller porting `use_hook_mlp_in=True` code may have meant
166
+ either side of the norm. The refusal names both: `mlp.hook_in` for the sublayer's argument,
167
+ `hook_resid_mid` for the pre-norm residual. `point_to_tlens_hook("attn_in")` emits `attn.hook_in`, the
168
+ name that denotes the tensor.
169
+
170
+ Two more names outside this table map (issue #25). `blocks.5.hook_in`, the block's forward pre-hook,
171
+ is `resid_pre` on a single-stream trunk — and is refused on a hyper-connection one, where it is the
172
+ _previous_ block's `resid_streams`, for the reason `attn_hc.hook_in` is. `unembed.hook_in` is the only
173
+ mapped name outside `blocks.{i}.`: the unembedding's input is `final_norm`, the final norm's output.
174
+ `ln_final.hook_normalized` is not that tensor — it fires pre-gain, and a `fold_ln` `HookedTransformer`
175
+ has folded that gain into `W_U` — so it is refused with the reason.
176
+
150
177
  ### `attn_in` is the attention module's input, not a norm's output
151
178
 
152
179
  Which matters on a post-norm family. On a Llama-shaped block the two coincide — the attention reads
@@ -553,7 +580,7 @@ path captures that module's **output** (`cache["model.layers.5.self_attn.q_proj"
553
580
  | `blocks.5.attn.hook_rot_q`, `.hook_rot_k` | q/k after RoPE | No such tensor crosses a module boundary: `transformers` applies RoPE inside the attention forward |
554
581
  | `blocks.5.attn.hook_result` | per-head output _after_ `W_O` | It is `n_heads` times the size of `z`, which is why TL gates it behind a flag too. Computed on demand instead: `capture.head_contributions(model, cache, 5)` applies the split `W_O` to a captured `z` |
555
582
  | `blocks.5.hook_q_input`, `.hook_k_input`, `.hook_v_input` | per-head writable inputs (`use_split_qkv_input`) | A TransformerLens parameterization rather than a place in the HF forward — the tensor exists only because TL splits the input per head to make it writable |
556
- | `blocks.5.hook_attn_in`, `blocks.5.hook_mlp_in` | writable sublayer inputs (`use_attn_in`) | Already named: `attn_in` / `mlp_in` are the same tensors, and writing to them needs no separate hook here |
583
+ | `blocks.5.hook_attn_in`, `blocks.5.hook_mlp_in` | the block-level sublayer inputs (`use_attn_in`, `use_hook_mlp_in`) | **Not** `attn_in` / `mlp_in`, despite the names: TransformerLens fires them on `resid_pre` / `resid_mid` _before_ the block norm, so they are the norm's input where our points are its output. Refused with both readings named; ask for `attn.hook_in` / `mlp.hook_in` (the sublayer's argument) or `hook_resid_pre` / `hook_resid_mid` (the tensor these hooks carry). [Below](#the-block-level-hook_attn_in-and-hook_mlp_in-are-the-norms-input) |
557
584
  | `blocks.5.mlp.hook_mid` | post-activation, pre-LayerNorm, on a SoLU model | No HF checkpoint in scope has an activation-internal norm, so the point would never resolve |
558
585
  | `blocks.5.mlp.hook_gate` | one expert's SwiGLU gate, at `blocks.5.mlp.experts.3.hook_gate` | **Not the router**, despite the name, so mapping it to anything routing-shaped would be wrong. Our `mlp_pre` is the same tensor on a dense MLP; per expert it is not a module output at all on the families that fuse their expert banks |
559
586
  | `blocks.5.mlp.hook_expert_weights` | the softmax over _all_ experts, pre-top-k | It is a different tensor from our `expert_weights` (post-top-k, `k` wide), and mapping two different tensors to one name is the failure this table exists to prevent. Write it as `softmax(cache["router_logits", 5])`, exact for the softmax-routed families |
@@ -204,7 +204,44 @@ async def generate():
204
204
  ```
205
205
 
206
206
  The stream yields decoded text deltas that concatenate to exactly what `generate_text` would have
207
- returned. For a chat model, build the prompt with the tokenizer's own template rather than by hand.
207
+ returned.
208
+
209
+ ### Sampling knobs
210
+
211
+ Every generation method takes `temperature`, `top_k`, `top_p` and `presence_penalty`, and decides
212
+ each one the same way on every backend: a knob you pass is used as passed; a knob you leave `None`
213
+ takes the checkpoint's own `generation_config.json`; a knob neither states is neutral (temperature
214
+ 1, no filtering, no penalty). This is what `transformers.generate` and `vllm serve` do, so a
215
+ checkpoint samples here the way its authors tuned it to, and `temperature=0.0` is greedy
216
+ everywhere. To turn a recommended filter off, say so in `transformers`' terms: `top_k=0`,
217
+ `top_p=1.0`.
218
+
219
+ `model.sampling_settings(...)` answers what a call with those arguments runs with, and
220
+ `model.recommended_sampling` is the file as read (`None` where it says nothing; Qwen3.5 ships no
221
+ file at all, so everything is neutral there unless you pass it):
222
+
223
+ ```python
224
+ from interp_engine import load_model
225
+
226
+ model = load_model("google/gemma-3-270m-it")
227
+ model.recommended_sampling # temperature 1.0, top_k 64, top_p 0.95
228
+ model.sampling_settings(temperature=0.3) # SamplingSettings(temperature=0.3, top_k=64, top_p=0.95, presence_penalty=0.0)
229
+ text = await model.generate_text(token_ids, max_tokens=64, temperature=0.3, presence_penalty=1.5)
230
+ ```
231
+
232
+ The presence penalty is the one repetition control: a flat subtraction from the logit of every
233
+ token the generation has produced so far, applied before the temperature and before a greedy
234
+ argmax, as vLLM orders it. It breaks a loop without scaling with the loop's length, which is what a
235
+ frequency penalty does and why that one punishes function words in a long reply. The file cannot
236
+ state a presence penalty (it is not a `transformers` field), so unset means 0; a card that
237
+ recommends one (Qwen3.5: 1.5) is for the caller to read. `transformers`' multiplicative
238
+ `repetition_penalty` is read but never applied.
239
+
240
+ Code that generates through `transformers.generate` directly gets the same decision with
241
+ `hf_generate_kwargs(model.sampling_settings(...), prompt_len=n)`: every knob stated, the file's
242
+ own `repetition_penalty` off, and the presence penalty as a logits processor.
243
+
244
+ For a chat model, build the prompt with the tokenizer's own template rather than by hand.
208
245
  Both backends carry a `Tokenize` helper on `.tok` for this:
209
246
 
210
247
  ```python
@@ -96,6 +96,16 @@ from interp_engine.residual_basis import (
96
96
  reduce_streams,
97
97
  vllm_residual_basis,
98
98
  )
99
+ from interp_engine.sampling import (
100
+ PresencePenaltyProcessor,
101
+ RecommendedSampling,
102
+ SamplingSettings,
103
+ VLLMSamplingKwargs,
104
+ apply_presence_penalty,
105
+ hf_generate_kwargs,
106
+ read_recommended_sampling,
107
+ resolve_sampling,
108
+ )
99
109
  from interp_engine.select import BackendSelection, select_backend
100
110
  from interp_engine.steer import (
101
111
  STEER_METHODS,
@@ -185,6 +195,15 @@ __all__ = [
185
195
  "HubKernelUnsupported",
186
196
  "deepgemm_fallback_kwargs",
187
197
  "InterpModel",
198
+ # Sampling: what the checkpoint recommends, and what a generation runs with.
199
+ "PresencePenaltyProcessor",
200
+ "RecommendedSampling",
201
+ "SamplingSettings",
202
+ "VLLMSamplingKwargs",
203
+ "apply_presence_penalty",
204
+ "hf_generate_kwargs",
205
+ "read_recommended_sampling",
206
+ "resolve_sampling",
188
207
  "VLLMModel",
189
208
  # That same surface without an event loop, for scripts and notebooks. The sync free
190
209
  # functions below dispatch through this, so most callers never name it.
@@ -56,9 +56,10 @@ _TLENS_STABLE: dict[str, str] = {
56
56
  # and that multiply -- a third tensor, wrong by an elementwise factor (cosine 0.89 on gemma-2
57
57
  # layer 19) and one that real Gemma Scope transcoders are trained on, so substituting these
58
58
  # would read a shipping artifact off the wrong activations. See :func:`tlens_hook_to_point`.
59
- "hook_mlp_in": "mlp_in",
59
+ #
60
+ # Nor the block-level `hook_mlp_in` / `hook_attn_in`, which fire BEFORE the norm runs -- the
61
+ # norm's input, a whole normalization away from these. Refused in `_TLENS_PRENORM_BLOCK_INPUTS`.
60
62
  "mlp.hook_in": "mlp_in",
61
- "hook_attn_in": "attn_in",
62
63
  "attn.hook_in": "attn_in",
63
64
  # The RAW module output, as distinct from the block-level hook below.
64
65
  "mlp.hook_out": "mlp_out",
@@ -146,6 +147,56 @@ _TLENS_STREAM_DEPENDENT: dict[str, tuple[str, str]] = {
146
147
  "hook_out": ("resid_post", "resid_streams"),
147
148
  }
148
149
 
150
+ # The block's own input, `hook_in`, is `resid_pre` on a single-stream trunk -- the mirror of
151
+ # `hook_out` above, and a forward pre-hook on the block is where a bridge puts it. On a
152
+ # hyper-connection trunk it is the stack ENTERING the block, which is the previous block's
153
+ # `resid_streams` and at layer 0 the embedding stack, so it is refused there with the same reason
154
+ # as `attn_hc.hook_in` rather than resolved to this layer's stack under a wrong index.
155
+ _TLENS_BLOCK_INPUT = "hook_in"
156
+
157
+ # The block-level sublayer inputs. TransformerLens fires `hook_mlp_in` on `resid_mid` and
158
+ # `hook_attn_in` on `resid_pre` (per head, `[batch, pos, n_heads, d_model]`), BEFORE the block norm
159
+ # runs. They look like `mlp_in`/`attn_in` and are not: those are the norm's OUTPUT, gain included,
160
+ # per the criterion in `_TLENS_STABLE`. The two differ by a whole normalization -- cosine 0.175 and
161
+ # a norm ratio of 231x at gemma-3-1b-it layer 5, past 3000x deeper in the stack; 27-86x on gpt2.
162
+ #
163
+ # Refused rather than resolved, for the reason `ln1/ln2.hook_normalized` are: substituting one for
164
+ # the other reads a shipping artifact off the wrong activations, silently, because the result is a
165
+ # plausible tensor of the right shape. Not remapped to the residual either -- a caller porting
166
+ # TransformerLens code may mean either side of the norm, and only they can say which.
167
+ _TLENS_PRENORM_BLOCK_INPUTS: dict[str, str] = {
168
+ "hook_mlp_in": (
169
+ "TransformerLens' block-level MLP input, fired on `resid_mid` BEFORE the block norm runs, so "
170
+ "it is the norm's input. The engine's `mlp_in` is the norm's output -- the tensor the MLP "
171
+ "receives. Ask for `mlp.hook_in` for the sublayer's argument, or `hook_resid_mid` for the "
172
+ "pre-norm residual this hook carries"
173
+ ),
174
+ "hook_attn_in": (
175
+ "TransformerLens' block-level attention input, fired on `resid_pre` BEFORE the block norm "
176
+ "runs (and broadcast per head), so it is the norm's input. The engine's `attn_in` is the "
177
+ "norm's output. Ask for `attn.hook_in` for the sublayer's argument, or `hook_resid_pre` for "
178
+ "the pre-norm residual this hook carries"
179
+ ),
180
+ }
181
+
182
+ # Hooks outside the `blocks.{i}.` namespace. `unembed.hook_in` is the unembedding's input, which is
183
+ # the final norm's output, gain included -- a forward pre-hook on `lm_head` reads the same tensor.
184
+ # TransformerLens 3 registers it on the bridge and on `HookedTransformer` alike.
185
+ _TLENS_GLOBAL: dict[str, str] = {"unembed.hook_in": "final_norm"}
186
+ _POINT_TO_TLENS_GLOBAL: dict[str, str] = {point: hook for hook, point in _TLENS_GLOBAL.items()}
187
+
188
+ # `ln_final.hook_normalized` is the nearest legacy name for the same tensor and is not it, twice
189
+ # over: the hook fires on `x / scale` before the gain, and `HookedTransformer` folds that gain into
190
+ # `W_U` by default (`fold_ln`), so on a folded model the hook IS the unembedding's input while the
191
+ # gain has moved into the weights. Neither reading is `final_norm`.
192
+ _TLENS_GLOBAL_REFUSED: dict[str, str] = {
193
+ "ln_final.hook_normalized": (
194
+ "a tensor inside the final norm -- `x / scale`, before the gain (which a `fold_ln` "
195
+ "HookedTransformer has folded into `W_U`). It is neither the norm's input nor `final_norm`, "
196
+ "the norm's output; `unembed.hook_in` is the output's name on TransformerLens 3's bridge"
197
+ ),
198
+ }
199
+
149
200
  # The two mHC hooks that are real tensors with no canonical point, refused by name so the reason
150
201
  # arrives instead of a list of near misses. Both are stream stacks, which is exactly why they are
151
202
  # dangerous: `resid_streams` has their shape, so a caller mapping by eye would take either.
@@ -176,7 +227,6 @@ UNMAPPED_TLENS: frozenset[str] = frozenset(
176
227
  {
177
228
  "attn_gate", # gated attention output; TL models no family that has one
178
229
  "embeddings", # TL's `hook_embed` is pre-positional/pre-scaling, a different tensor
179
- "final_norm", # `ln_final.hook_normalized` is outside the `blocks.{i}.` namespace
180
230
  "lm_head", # TL returns logits rather than hooking the unembed
181
231
  "router_logits", # TL hooks the softmax over all experts, not the logits
182
232
  "expert_weights", # TL's `hook_expert_weights` is pre-top-k, so it is a different tensor
@@ -185,14 +235,14 @@ UNMAPPED_TLENS: frozenset[str] = frozenset(
185
235
 
186
236
  # Canonical point -> the TransformerLens name to emit, for the rest. `mlp_out_post`/`attn_out_post`
187
237
  # are ours alone as *names*, but TL reaches those tensors through its aliased block-level hook, which
188
- # is what this emits.
238
+ # is what this emits. Per-layer points only; the global ones are in `_POINT_TO_TLENS_GLOBAL`.
189
239
  _POINT_TO_TLENS: dict[str, str] = {
190
240
  "resid_pre": "hook_resid_pre",
191
241
  "resid_post": "hook_resid_post",
192
242
  "resid_mid": "hook_resid_mid",
193
243
  "mlp_in": "mlp.hook_in",
194
244
  "mlp_out": "mlp.hook_out",
195
- "attn_in": "hook_attn_in",
245
+ "attn_in": "attn.hook_in",
196
246
  "attn_out": "attn.hook_out",
197
247
  "mlp_out_post": "hook_mlp_out",
198
248
  "attn_out_post": "hook_attn_out",
@@ -330,7 +380,10 @@ def tlens_hook_to_point(hook_name: str, model: object | None = None) -> Address:
330
380
  conventional trunk and ``resid_streams`` on a hyper-connection one, because TransformerLens 3's
331
381
  DeepSeek-V4 bridge drops the ``hook_resid_post`` alias and returns the stream stack from that
332
382
  hook instead. Without ``model`` it resolves to ``resid_post``, the reading that is right almost
333
- everywhere -- and wrong by a rank on DeepSeek-V4 and Motif 3.
383
+ everywhere -- and wrong by a rank on DeepSeek-V4 and Motif 3. Its mirror ``hook_in`` is
384
+ ``resid_pre`` on a conventional trunk and is *refused* on a hyper-connection one: the stack
385
+ entering block ``i`` is block ``i-1``'s ``resid_streams``, and resolving it under index ``i``
386
+ would name a real tensor one layer off.
334
387
 
335
388
  The two mHC hooks that carry a stream stack *in* are refused rather than mapped, with the reason:
336
389
  ``attn_hc.hook_in`` is the previous block's ``resid_streams`` and ``mlp_hc.hook_in`` is the
@@ -338,6 +391,12 @@ def tlens_hook_to_point(hook_name: str, model: object | None = None) -> Address:
338
391
  its shape. The mHC hooks that come *out* -- ``hook_post``, ``hook_comb``, ``hook_out`` at both
339
392
  the ``attn_hc`` and ``mlp_hc`` sites -- all map, and need no ``model``.
340
393
 
394
+ The block-level ``hook_mlp_in``/``hook_attn_in`` are refused, because they are not the sublayer
395
+ inputs their names suggest: TransformerLens fires them on ``resid_mid``/``resid_pre`` *before*
396
+ the block norm, so they are the norm's input where ``mlp_in``/``attn_in`` are its output. The
397
+ sublayer-scoped ``mlp.hook_in``/``attn.hook_in`` are the output and map. The refusal names both,
398
+ since a caller may mean either side of the norm.
399
+
341
400
  ``ln1.hook_normalized``/``ln2.hook_normalized`` are unmapped for a reason worth stating, because
342
401
  they look like the sublayer inputs: TransformerLens fires them on ``x / scale``, before the norm's
343
402
  gain, so they are neither the norm's input (``resid_pre``/``resid_mid``) nor its output
@@ -347,6 +406,10 @@ def tlens_hook_to_point(hook_name: str, model: object | None = None) -> Address:
347
406
  own drift, not the formula). Gemma Scope's transcoders are trained there, so this is a live
348
407
  distinction, not a pedantic one.
349
408
 
409
+ One name outside the ``blocks.{i}.`` namespace maps: ``unembed.hook_in`` is ``final_norm``,
410
+ the unembedding's input. ``ln_final.hook_normalized`` is refused for the ``hook_normalized``
411
+ reason above, plus one more -- a ``fold_ln`` HookedTransformer has moved the gain into ``W_U``.
412
+
350
413
  Two QK-norm hooks are intentionally unmapped rather than approximated. ``q_norm.hook_scale`` is
351
414
  the norm's own denominator, an intermediate of its arithmetic that no hook on the module can
352
415
  return -- recompute it from ``q_norm_in`` and ``facts.rms_norm_eps``, which reproduces TL's
@@ -361,10 +424,16 @@ def tlens_hook_to_point(hook_name: str, model: object | None = None) -> Address:
361
424
  ``softmax(cache["router_logits", layer])``, which is exact for the softmax-routed families and
362
425
  is *not* what gpt-oss or DeepSeek-V3 compute.
363
426
  """
427
+ if hook_name in _TLENS_GLOBAL:
428
+ return Address(_TLENS_GLOBAL[hook_name])
429
+ if hook_name in _TLENS_GLOBAL_REFUSED:
430
+ raise UnmappedHook(f"TransformerLens hook {hook_name!r} is {_TLENS_GLOBAL_REFUSED[hook_name]}.")
431
+
364
432
  match = _BLOCK_RE.match(hook_name)
365
433
  if not match:
366
434
  raise UnmappedHook(
367
- f"Cannot parse a layer out of TransformerLens hook name {hook_name!r} (expected 'blocks.<layer>.<hook>')"
435
+ f"Cannot parse a layer out of TransformerLens hook name {hook_name!r} (expected "
436
+ f"'blocks.<layer>.<hook>', or one of {', '.join(sorted(_TLENS_GLOBAL))})"
368
437
  )
369
438
  layer, suffix = int(match.group(1)), match.group(2)
370
439
 
@@ -376,6 +445,15 @@ def tlens_hook_to_point(hook_name: str, model: object | None = None) -> Address:
376
445
  if suffix in _TLENS_STREAM_DEPENDENT:
377
446
  single, stacked = _TLENS_STREAM_DEPENDENT[suffix]
378
447
  return Address(stacked if model is not None and has_hyper_connections(model) else single, layer)
448
+ if suffix == _TLENS_BLOCK_INPUT:
449
+ if model is not None and has_hyper_connections(model):
450
+ raise UnmappedHook(
451
+ f"TransformerLens hook {hook_name!r} is {_TLENS_STREAM_STACK_INPUTS['attn_hc.hook_in']}."
452
+ )
453
+ return Address("resid_pre", layer)
454
+
455
+ if suffix in _TLENS_PRENORM_BLOCK_INPUTS:
456
+ raise UnmappedHook(f"TransformerLens hook {hook_name!r} is {_TLENS_PRENORM_BLOCK_INPUTS[suffix]}.")
379
457
 
380
458
  if suffix in _TLENS_STREAM_STACK_INPUTS:
381
459
  raise UnmappedHook(f"TransformerLens hook {hook_name!r} is {_TLENS_STREAM_STACK_INPUTS[suffix]}.")
@@ -388,7 +466,7 @@ def tlens_hook_to_point(hook_name: str, model: object | None = None) -> Address:
388
466
  f"and `capture.pre_gain_normalized` for the arithmetic."
389
467
  )
390
468
 
391
- known = sorted({*_TLENS_STABLE, *_TLENS_CONTRIBUTION, *_TLENS_STREAM_DEPENDENT})
469
+ known = sorted({*_TLENS_STABLE, *_TLENS_CONTRIBUTION, *_TLENS_STREAM_DEPENDENT, _TLENS_BLOCK_INPUT})
392
470
  raise UnmappedHook(f"TransformerLens hook {hook_name!r} has no canonical point (known: {', '.join(known)})")
393
471
 
394
472
 
@@ -455,8 +533,14 @@ def point_to_tlens_hook(point: str | Address, layer: int | None = None) -> str:
455
533
 
456
534
  Takes an :class:`~interp_engine.address.Address` or the ``(point, layer)`` pair. An address
457
535
  carrying a coordinate TransformerLens cannot express raises rather than losing it -- see
458
- :func:`_refuse_unmappable_coordinates`.
536
+ :func:`_refuse_unmappable_coordinates`. The one global point with a name, ``final_norm``, takes
537
+ no layer and emits ``unembed.hook_in``.
459
538
  """
539
+ name = point.name if isinstance(point, Address) else point
540
+ if name in _POINT_TO_TLENS_GLOBAL:
541
+ if (point.layer if isinstance(point, Address) else layer) is not None:
542
+ raise UnmappedHook(f"{name!r} is a global point and takes no layer")
543
+ return _POINT_TO_TLENS_GLOBAL[name]
460
544
  point, layer, address = _split(point, layer)
461
545
  _refuse_unmappable_coordinates(address, "TransformerLens", "blocks.{i}.{hook}")
462
546
  if point not in _POINT_TO_TLENS:
@@ -467,7 +551,7 @@ def point_to_tlens_hook(point: str | Address, layer: int | None = None) -> str:
467
551
  "is a canonical point that TransformerLens has no equivalent for" if known else "is not a canonical point"
468
552
  )
469
553
  raise UnmappedHook(
470
- f"{point!r} {reason} (mappable: {', '.join(sorted(_POINT_TO_TLENS))})"
554
+ f"{point!r} {reason} (mappable: {', '.join(sorted({*_POINT_TO_TLENS, *_POINT_TO_TLENS_GLOBAL}))})"
471
555
  + (f"; ours alone: {', '.join(sorted(UNMAPPED_TLENS))}" if known else "")
472
556
  )
473
557
  return f"blocks.{layer}.{_POINT_TO_TLENS[point]}"
@@ -44,6 +44,7 @@ from interp_engine.facts import factored_projection, text_config
44
44
  from interp_engine.points import PointSpec, Scope, known_names, point_spec, points_for
45
45
  from interp_engine.protocol import Completion
46
46
  from interp_engine.residual_basis import ResidualBasis, eager_residual_basis
47
+ from interp_engine.sampling import RecommendedSampling, SamplingSettings, read_recommended_sampling, resolve_sampling
47
48
  from interp_engine.tokenize import Tokenize
48
49
 
49
50
  logger = logging.getLogger(__name__)
@@ -504,6 +505,7 @@ class EagerModel:
504
505
  self._requires_grad = requires_grad
505
506
  # Lazily computed in `grad_support`, never here: a verdict must not be part of loading.
506
507
  self._grad_support: GradSupport | None = None
508
+ self._recommended_sampling: RecommendedSampling | None = None
507
509
  torch_dtype = _normalize_dtype(dtype)
508
510
  model_kwargs = dict(model_kwargs or {})
509
511
  # Resolved once, and only when something is actually fetched by id: a caller handing over
@@ -623,6 +625,33 @@ class EagerModel:
623
625
  def default_prepend_bos(self) -> bool:
624
626
  return self._default_prepend_bos
625
627
 
628
+ @property
629
+ def recommended_sampling(self) -> RecommendedSampling:
630
+ """Read when first asked, never at load: a caller handing over its own ``hf_model`` and
631
+ ``tokenizer`` may use an id that resolves to nothing, and gets an empty answer here."""
632
+ # `getattr`: a test double built without `__init__` has no slot yet.
633
+ stated = getattr(self, "_recommended_sampling", None)
634
+ if stated is None:
635
+ stated = self._recommended_sampling = read_recommended_sampling(self.hf_model_id)
636
+ return stated
637
+
638
+ def sampling_settings(
639
+ self,
640
+ *,
641
+ temperature: float | None = None,
642
+ top_k: int | None = None,
643
+ top_p: float | None = None,
644
+ presence_penalty: float | None = None,
645
+ ) -> SamplingSettings:
646
+ """See the protocol."""
647
+ return resolve_sampling(
648
+ self.recommended_sampling,
649
+ temperature=temperature,
650
+ top_k=top_k,
651
+ top_p=top_p,
652
+ presence_penalty=presence_penalty,
653
+ )
654
+
626
655
  # --- classmethod convenience --------------------------------------------
627
656
  @classmethod
628
657
  def from_pretrained(
@@ -1273,7 +1302,15 @@ class EagerModel:
1273
1302
  return capture_attention_eager(self, [int(t) for t in prompt_token_ids], layers)
1274
1303
 
1275
1304
  def _generate_completion(
1276
- self, prompt_ids: list[int], max_tokens: int, temperature: float, seed: int | None
1305
+ self,
1306
+ prompt_ids: list[int],
1307
+ max_tokens: int,
1308
+ temperature: float | None,
1309
+ seed: int | None,
1310
+ *,
1311
+ top_k: int | None = None,
1312
+ top_p: float | None = None,
1313
+ presence_penalty: float | None = None,
1277
1314
  ) -> Completion:
1278
1315
  from interp_engine.steer import generate_stream
1279
1316
 
@@ -1283,6 +1320,9 @@ class EagerModel:
1283
1320
  torch.tensor([prompt_ids], device=self.device),
1284
1321
  max_tokens=max_tokens,
1285
1322
  temperature=temperature,
1323
+ top_k=top_k,
1324
+ top_p=top_p,
1325
+ presence_penalty=presence_penalty,
1286
1326
  seed=seed,
1287
1327
  )
1288
1328
  )
@@ -1296,18 +1336,32 @@ class EagerModel:
1296
1336
  prompt_token_ids: Any,
1297
1337
  *,
1298
1338
  max_tokens: int = 200,
1299
- temperature: float = 1.0,
1339
+ temperature: float | None = None,
1340
+ top_k: int | None = None,
1341
+ top_p: float | None = None,
1342
+ presence_penalty: float | None = None,
1300
1343
  seed: int | None = None,
1301
1344
  ) -> str:
1302
1345
  """Generate and return the completion text. See the protocol."""
1303
- return self._generate_completion([int(t) for t in prompt_token_ids], max_tokens, temperature, seed).text
1346
+ return self._generate_completion(
1347
+ [int(t) for t in prompt_token_ids],
1348
+ max_tokens,
1349
+ temperature,
1350
+ seed,
1351
+ top_k=top_k,
1352
+ top_p=top_p,
1353
+ presence_penalty=presence_penalty,
1354
+ ).text
1304
1355
 
1305
1356
  async def generate_stream(
1306
1357
  self,
1307
1358
  prompt_token_ids: Any,
1308
1359
  *,
1309
1360
  max_tokens: int = 200,
1310
- temperature: float = 1.0,
1361
+ temperature: float | None = None,
1362
+ top_k: int | None = None,
1363
+ top_p: float | None = None,
1364
+ presence_penalty: float | None = None,
1311
1365
  seed: int | None = None,
1312
1366
  ) -> AsyncIterator[str]:
1313
1367
  """Yield decoded text deltas, one per token. See the protocol.
@@ -1323,6 +1377,9 @@ class EagerModel:
1323
1377
  torch.tensor([[int(t) for t in prompt_token_ids]], device=self.device),
1324
1378
  max_tokens=max_tokens,
1325
1379
  temperature=temperature,
1380
+ top_k=top_k,
1381
+ top_p=top_p,
1382
+ presence_penalty=presence_penalty,
1326
1383
  seed=seed,
1327
1384
  ):
1328
1385
  yield step.token_str
@@ -47,6 +47,7 @@ import torch
47
47
  from interp_engine.address import Address
48
48
  from interp_engine.autograd_support import GradSupport
49
49
  from interp_engine.residual_basis import ResidualBasis
50
+ from interp_engine.sampling import RecommendedSampling, SamplingSettings
50
51
 
51
52
  # Re-exported so a protocol-typed caller needs one import. The type itself lives in
52
53
  # `interp_engine.address`, which owns the grammar; this module defines a Protocol and should not.
@@ -150,6 +151,34 @@ class InterpModel(Protocol):
150
151
  """
151
152
  ...
152
153
 
154
+ # --- sampling -----------------------------------------------------------
155
+ @property
156
+ def recommended_sampling(self) -> RecommendedSampling:
157
+ """What the checkpoint's ``generation_config.json`` states: temperature, top-k, top-p,
158
+ ``do_sample``. Empty when the checkpoint has none (Qwen3.5) or the file names only token
159
+ ids (GPT-2). The file is Hugging Face's format, so a presence penalty cannot appear here
160
+ whatever the model card says.
161
+ """
162
+ ...
163
+
164
+ def sampling_settings(
165
+ self,
166
+ *,
167
+ temperature: float | None = None,
168
+ top_k: int | None = None,
169
+ top_p: float | None = None,
170
+ presence_penalty: float | None = None,
171
+ ) -> SamplingSettings:
172
+ """What a generation called with these arguments runs with, every knob decided.
173
+
174
+ The rule is :func:`interp_engine.sampling.resolve_sampling`: a knob passed is used as
175
+ passed, a knob left ``None`` takes :attr:`recommended_sampling`, and a knob neither states
176
+ is neutral (temperature 1, no filtering, no penalty). Every generation method below
177
+ resolves its knobs this way, so a run is reproducible from its arguments plus the
178
+ checkpoint; a server reports the result beside the completion.
179
+ """
180
+ ...
181
+
153
182
  # --- tokenization -------------------------------------------------------
154
183
  tokenizer: Any
155
184
  """The HF tokenizer (or processor on multimodal archs), for chat templating and
@@ -254,10 +283,17 @@ class InterpModel(Protocol):
254
283
  prompt_token_ids: Sequence[int],
255
284
  *,
256
285
  max_tokens: int = 200,
257
- temperature: float = 1.0,
286
+ temperature: float | None = None,
287
+ top_k: int | None = None,
288
+ top_p: float | None = None,
289
+ presence_penalty: float | None = None,
258
290
  seed: int | None = None,
259
291
  ) -> str:
260
- """Generate and return the completion text (no prompt echo)."""
292
+ """Generate and return the completion text (no prompt echo).
293
+
294
+ Sampling knobs left ``None`` take the checkpoint's recommendation; see
295
+ :meth:`sampling_settings`.
296
+ """
261
297
  ...
262
298
 
263
299
  def generate_stream(
@@ -265,7 +301,10 @@ class InterpModel(Protocol):
265
301
  prompt_token_ids: Sequence[int],
266
302
  *,
267
303
  max_tokens: int = 200,
268
- temperature: float = 1.0,
304
+ temperature: float | None = None,
305
+ top_k: int | None = None,
306
+ top_p: float | None = None,
307
+ presence_penalty: float | None = None,
269
308
  seed: int | None = None,
270
309
  ) -> AsyncIterator[str]:
271
310
  """Yield decoded text deltas as they are produced.