interp-engine 1.3.0__tar.gz → 1.3.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 (174) hide show
  1. {interp_engine-1.3.0 → interp_engine-1.3.2}/PKG-INFO +3 -6
  2. {interp_engine-1.3.0 → interp_engine-1.3.2}/README.md +2 -5
  3. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/chat_formatters.py +27 -0
  4. {interp_engine-1.3.0 → interp_engine-1.3.2}/pyproject.toml +1 -1
  5. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_chat_formatters.py +87 -18
  6. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_doc_code_fences.py +12 -1
  7. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_points_registry.py +28 -0
  8. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_release.py +50 -0
  9. {interp_engine-1.3.0 → interp_engine-1.3.2}/.gitignore +0 -0
  10. {interp_engine-1.3.0 → interp_engine-1.3.2}/LICENSE +0 -0
  11. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/README.md +0 -0
  12. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/__init__.py +0 -0
  13. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/bench_spec.py +0 -0
  14. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/cells.py +0 -0
  15. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/probe.py +0 -0
  16. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/probe_lens_stream.py +0 -0
  17. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/publish.py +0 -0
  18. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/report_bench.py +0 -0
  19. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/deepseek-v4-flash-0731__eager.json +0 -0
  20. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/deepseek-v4-flash-0731__vllm-cudagraph.json +0 -0
  21. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark-cudagraph.json +0 -0
  22. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark.json +0 -0
  23. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/deepseek-v4-flash-0731__vllm-static.json +0 -0
  24. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/deepseek-v4-flash-0731__vllm.json +0 -0
  25. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/gemma-2-2b__eager.json +0 -0
  26. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/gemma-2-2b__vllm-cudagraph.json +0 -0
  27. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/gemma-2-2b__vllm-static.json +0 -0
  28. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/gemma-2-2b__vllm.json +0 -0
  29. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/llama-3.1-8b__eager.json +0 -0
  30. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/llama-3.1-8b__vllm-cudagraph.json +0 -0
  31. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/llama-3.1-8b__vllm-static.json +0 -0
  32. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/llama-3.1-8b__vllm.json +0 -0
  33. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/qwen3-4b__eager.json +0 -0
  34. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/qwen3-4b__vllm-cudagraph.json +0 -0
  35. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/qwen3-4b__vllm-static.json +0 -0
  36. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/qwen3-4b__vllm.json +0 -0
  37. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/qwen3.8-27b__eager.json +0 -0
  38. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/qwen3.8-27b__vllm-cudagraph.json +0 -0
  39. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/qwen3.8-27b__vllm-static.json +0 -0
  40. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results/qwen3.8-27b__vllm.json +0 -0
  41. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/results-latest.md +0 -0
  42. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/run_all.sh +0 -0
  43. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/run_bench.py +0 -0
  44. {interp_engine-1.3.0 → interp_engine-1.3.2}/benchmarks/workloads.py +0 -0
  45. {interp_engine-1.3.0 → interp_engine-1.3.2}/docs/AGENT_INTEGRATION.md +0 -0
  46. {interp_engine-1.3.0 → interp_engine-1.3.2}/docs/ARCHITECTURE_QUIRKS.md +0 -0
  47. {interp_engine-1.3.0 → interp_engine-1.3.2}/docs/COMPATIBILITY.md +0 -0
  48. {interp_engine-1.3.0 → interp_engine-1.3.2}/docs/ENGINE_HOOK_MAPPINGS.md +0 -0
  49. {interp_engine-1.3.0 → interp_engine-1.3.2}/docs/GRADIENTS.md +0 -0
  50. {interp_engine-1.3.0 → interp_engine-1.3.2}/docs/INTERNALS.md +0 -0
  51. {interp_engine-1.3.0 → interp_engine-1.3.2}/docs/PERFORMANCE.md +0 -0
  52. {interp_engine-1.3.0 → interp_engine-1.3.2}/docs/PORTING.md +0 -0
  53. {interp_engine-1.3.0 → interp_engine-1.3.2}/docs/README.md +0 -0
  54. {interp_engine-1.3.0 → interp_engine-1.3.2}/docs/SUPPORTED_POINTS.md +0 -0
  55. {interp_engine-1.3.0 → interp_engine-1.3.2}/docs/USAGE.md +0 -0
  56. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/__init__.py +0 -0
  57. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/_loop.py +0 -0
  58. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/address.py +0 -0
  59. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/arch.py +0 -0
  60. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/attn_config.py +0 -0
  61. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/attn_scores.py +0 -0
  62. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/autograd_support.py +0 -0
  63. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/capture.py +0 -0
  64. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/chat_compose.py +0 -0
  65. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/chat_conventions.py +0 -0
  66. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/cuda_preflight.py +0 -0
  67. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/dispatch.py +0 -0
  68. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/facts.py +0 -0
  69. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/hooks.py +0 -0
  70. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/lens.py +0 -0
  71. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/load.py +0 -0
  72. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/mappers.py +0 -0
  73. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/model.py +0 -0
  74. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/moe_routing.py +0 -0
  75. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/points.py +0 -0
  76. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/protocol.py +0 -0
  77. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/residual_basis.py +0 -0
  78. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/select.py +0 -0
  79. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/steer.py +0 -0
  80. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/steer_specs.py +0 -0
  81. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/sync.py +0 -0
  82. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/tokenize.py +0 -0
  83. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_backend.py +0 -0
  84. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/__init__.py +0 -0
  85. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/_demux.py +0 -0
  86. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/_hooks.py +0 -0
  87. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/_payload.py +0 -0
  88. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/_tree.py +0 -0
  89. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/attn.py +0 -0
  90. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/capture.py +0 -0
  91. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/graphs.py +0 -0
  92. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/lens/__init__.py +0 -0
  93. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/lens/intervene.py +0 -0
  94. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/lens/readout.py +0 -0
  95. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/lens/unembed.py +0 -0
  96. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/mhc.py +0 -0
  97. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/native.py +0 -0
  98. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/requests.py +0 -0
  99. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/static.py +0 -0
  100. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_capture/steering.py +0 -0
  101. {interp_engine-1.3.0 → interp_engine-1.3.2}/interp_engine/vllm_plugin.py +0 -0
  102. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/conftest.py +0 -0
  103. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/harness.py +0 -0
  104. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/model_expectations.yaml +0 -0
  105. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/synthetic_families.py +0 -0
  106. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_address.py +0 -0
  107. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_attn_config_tripwire.py +0 -0
  108. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_attn_probs_indexing.py +0 -0
  109. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_attn_scores.py +0 -0
  110. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_attn_z_gqa.py +0 -0
  111. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_autograd_support.py +0 -0
  112. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_bench_workloads.py +0 -0
  113. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_capability_refusals.py +0 -0
  114. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_capture_addressing.py +0 -0
  115. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_chat_compose.py +0 -0
  116. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_chat_templates.py +0 -0
  117. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_core.py +0 -0
  118. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_cuda_preflight.py +0 -0
  119. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_eager_autograd.py +0 -0
  120. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_facts.py +0 -0
  121. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_family_points.py +0 -0
  122. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_gated_attn_out.py +0 -0
  123. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_head_contributions.py +0 -0
  124. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_hook_call_conventions.py +0 -0
  125. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_layer_kinds.py +0 -0
  126. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_load.py +0 -0
  127. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_logit_transform.py +0 -0
  128. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_mappers.py +0 -0
  129. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_mlp_internals.py +0 -0
  130. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_model_expectations.py +0 -0
  131. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_moe.py +0 -0
  132. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_multimodal_arch.py +0 -0
  133. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_new_models_gpu.py +0 -0
  134. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_no_chat_template.py +0 -0
  135. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_normalized_hook.py +0 -0
  136. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_packaging.py +0 -0
  137. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_parity_gpt2.py +0 -0
  138. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_per_layer_attn_dims.py +0 -0
  139. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_protocol.py +0 -0
  140. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_published_benchmarks.py +0 -0
  141. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_qk_norm.py +0 -0
  142. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_qkv_layout.py +0 -0
  143. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_reasoning_spans.py +0 -0
  144. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_resid_mid.py +0 -0
  145. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_residual_basis.py +0 -0
  146. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_sandwich_norms.py +0 -0
  147. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_select.py +0 -0
  148. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_sliding_window_attn.py +0 -0
  149. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_small_models_gpu.py +0 -0
  150. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_static_dsv4_gpu.py +0 -0
  151. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_static_parity_gpu.py +0 -0
  152. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_static_set.py +0 -0
  153. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_static_warmup.py +0 -0
  154. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_steer_context.py +0 -0
  155. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_steer_math_parity.py +0 -0
  156. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_sync_loop.py +0 -0
  157. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_sync_parity.py +0 -0
  158. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_unified_free_functions.py +0 -0
  159. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_unresolved_families.py +0 -0
  160. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vllm_capture_gpu.py +0 -0
  161. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vllm_capture_scales.py +0 -0
  162. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vllm_engine_loop.py +0 -0
  163. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vllm_graph_path.py +0 -0
  164. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vllm_graphs_on_gpu.py +0 -0
  165. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vllm_hook_availability.py +0 -0
  166. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vllm_hyper_connections.py +0 -0
  167. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vllm_kv_isolation.py +0 -0
  168. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vllm_new_points.py +0 -0
  169. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vllm_only_families.py +0 -0
  170. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vllm_plugin.py +0 -0
  171. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vllm_wire_grammar.py +0 -0
  172. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_vocabulary_boundary.py +0 -0
  173. {interp_engine-1.3.0 → interp_engine-1.3.2}/tests/test_worker_lens_capture_readout.py +0 -0
  174. {interp_engine-1.3.0 → interp_engine-1.3.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.3.0
3
+ Version: 1.3.2
4
4
  Summary: A fast, standardized interpretability engine that supports most modern models and architectures. Powers Neuronpedia.
5
5
  Project-URL: Homepage, https://github.com/decoderesearch/interp-engine
6
6
  Project-URL: Repository, https://github.com/decoderesearch/interp-engine
@@ -44,7 +44,6 @@ Description-Content-Type: text/markdown
44
44
  <a href="https://join.slack.com/t/opensourcemechanistic/shared_invite/zt-3z9o0hxjl-MDX9pbATO2qESOazNDLpdQ"><img src="https://img.shields.io/badge/Slack-Open%20Source%20Mechanistic%20Interpretability-4A154B?logo=slack&logoColor=white" alt="Join the Slack"></a>
45
45
  </p>
46
46
 
47
-
48
47
  `interp-engine` is an interpretability engine that is fast (>40x tok/s vs HF eager), standardized (34 'points'/addresses across architectures), and easy to use and debug. It powers all of [Neuronpedia](https://neuronpedia.org)'s inference and is checked for accuracy against HF Transformers and other engines.
49
48
 
50
49
  <p align="center">
@@ -98,7 +97,7 @@ Add "use interp-engine" to your prompt and let your agent figure it out - everyt
98
97
 
99
98
  ## Performance / Speed
100
99
 
101
- vLLM gives `interp-engine` high throughput via concurrency, and `backend="vllm-static"` adds CUDA-graph replay on top of that *without giving up capture or steering*. Every column below is capture-capable.
100
+ vLLM gives `interp-engine` high throughput via concurrency, and `backend="vllm-static"` gives even higher throughput at the cost of higher VRAM usage. Every column below is capture-capable.
102
101
 
103
102
  <!-- THROUGHPUT:START -->
104
103
 
@@ -137,9 +136,7 @@ We verify correctness in two main ways:
137
136
  1. A test suite that checks results across several models - what each check is designed to catch is in [INTERNALS.md](docs/INTERNALS.md#correctness).
138
137
  2. A full `validator` comparison engine that checks most hook points across 50+ models, at early, middle and late layers - fully reproducible, with detailed results saved in the git repo at [`validator/`](validator/).
139
138
 
140
- ## Why use an Interpretability Engine, instead of just having my AI code whatever it needs on the fly?
141
-
142
- Software is easy to make in 2026, here's why an engine makes sense:
139
+ ## Why use an Interpretability Engine instead of building from scratch?
143
140
 
144
141
  1. **Speed**: Get performance without sacrificing correctness.
145
142
  2. **Standardization + Verification**: Eliminate ambiguity when referring to points, plus a full test suite included.
@@ -13,7 +13,6 @@
13
13
  <a href="https://join.slack.com/t/opensourcemechanistic/shared_invite/zt-3z9o0hxjl-MDX9pbATO2qESOazNDLpdQ"><img src="https://img.shields.io/badge/Slack-Open%20Source%20Mechanistic%20Interpretability-4A154B?logo=slack&logoColor=white" alt="Join the Slack"></a>
14
14
  </p>
15
15
 
16
-
17
16
  `interp-engine` is an interpretability engine that is fast (>40x tok/s vs HF eager), standardized (34 'points'/addresses across architectures), and easy to use and debug. It powers all of [Neuronpedia](https://neuronpedia.org)'s inference and is checked for accuracy against HF Transformers and other engines.
18
17
 
19
18
  <p align="center">
@@ -67,7 +66,7 @@ Add "use interp-engine" to your prompt and let your agent figure it out - everyt
67
66
 
68
67
  ## Performance / Speed
69
68
 
70
- vLLM gives `interp-engine` high throughput via concurrency, and `backend="vllm-static"` adds CUDA-graph replay on top of that *without giving up capture or steering*. Every column below is capture-capable.
69
+ vLLM gives `interp-engine` high throughput via concurrency, and `backend="vllm-static"` gives even higher throughput at the cost of higher VRAM usage. Every column below is capture-capable.
71
70
 
72
71
  <!-- THROUGHPUT:START -->
73
72
 
@@ -106,9 +105,7 @@ We verify correctness in two main ways:
106
105
  1. A test suite that checks results across several models - what each check is designed to catch is in [INTERNALS.md](docs/INTERNALS.md#correctness).
107
106
  2. A full `validator` comparison engine that checks most hook points across 50+ models, at early, middle and late layers - fully reproducible, with detailed results saved in the git repo at [`validator/`](validator/).
108
107
 
109
- ## Why use an Interpretability Engine, instead of just having my AI code whatever it needs on the fly?
110
-
111
- Software is easy to make in 2026, here's why an engine makes sense:
108
+ ## Why use an Interpretability Engine instead of building from scratch?
112
109
 
113
110
  1. **Speed**: Get performance without sacrificing correctness.
114
111
  2. **Standardization + Verification**: Eliminate ambiguity when referring to points, plus a full test suite included.
@@ -188,6 +188,7 @@ class DeepseekV4Formatter:
188
188
  prefix = self._bos + "".join(blocks[: len(lead)])
189
189
  blocks = blocks[len(lead) :]
190
190
 
191
+ self._reassign_interior_openers(blocks, turns)
191
192
  scaffold = self._split_scaffold(blocks, turns)
192
193
  return RenderedChat(prefix=prefix, blocks=tuple(blocks), suffix=scaffold if add_generation_prompt else "")
193
194
 
@@ -270,6 +271,32 @@ class DeepseekV4Formatter:
270
271
  turn.setdefault(self._reasoning_key, value)
271
272
  return turn
272
273
 
274
+ def _reassign_interior_openers(self, blocks: list[str], turns: Sequence[Message]) -> None:
275
+ """Move each assistant opener onto the assistant turn it opens, in place.
276
+
277
+ The encoder writes ``<|Assistant|>`` plus a thinking delimiter at the *tail* of every
278
+ user/developer turn -- including interior ones that a completed assistant answer follows,
279
+ not just the last turn whose opener is the generation scaffold. Because ``_tails``
280
+ decomposes the render by common suffix, that opener lands at the end of the *user* block,
281
+ so span metadata attributes it to the user turn: the opener renders inside the user's
282
+ bubble and the assistant's bubble starts on bare content. This peels it off the user turn
283
+ and prepends it to the assistant turn it belongs to, leaving ``RenderedChat.text``
284
+ unchanged (a substring only moves across the block boundary). The final turn's opener has
285
+ no assistant turn after it and is handled by :meth:`_split_scaffold` instead.
286
+ """
287
+ for k in range(len(blocks) - 1):
288
+ turn = turns[k]
289
+ if turn.get("role") not in ("user", "developer") or turn.get("task") is not None:
290
+ continue
291
+ if turns[k + 1].get("role") != "assistant":
292
+ continue
293
+ for delimiter in (self._think_open, self._think_close):
294
+ opener = self._assistant_token + delimiter
295
+ if blocks[k].endswith(opener):
296
+ blocks[k] = blocks[k][: -len(opener)]
297
+ blocks[k + 1] = opener + blocks[k + 1]
298
+ break
299
+
273
300
  def _split_scaffold(self, blocks: list[str], turns: Sequence[Message]) -> str:
274
301
  """Remove and return the trailing generation scaffold from ``blocks``, or ``""``.
275
302
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "interp-engine"
3
- version = "1.3.0"
3
+ version = "1.3.2"
4
4
  description = "A fast, standardized interpretability engine that supports most modern models and architectures. Powers Neuronpedia."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -66,9 +66,12 @@ def _stub_encoder(reasoning_key: str = "reasoning_content") -> ModuleType:
66
66
  """A module with the surface :class:`DeepseekV4Formatter` reads, and the same semantics.
67
67
 
68
68
  Not a second implementation of the format -- it renders a deliberately simplified one. What
69
- it reproduces faithfully is the three behaviours the formatter has to work around: the
70
- trailing ``<|Assistant|>`` scaffold appears whether or not you asked for it, ``wo_eos``
71
- is how a turn is held open, and ``context`` decomposes the render per message.
69
+ it reproduces faithfully is the four behaviours the formatter has to work around: the
70
+ trailing ``<|Assistant|>`` scaffold appears whether or not you asked for it (and rides on
71
+ the user turn it follows, not the assistant turn it opens), ``wo_eos`` is how a turn is held
72
+ open, ``context`` decomposes the render per message, and ``drop_thinking`` strips a completed
73
+ answer's reasoning once a later user turn demotes it to history -- the non-monotonic rewrite
74
+ this whole module exists for.
72
75
  """
73
76
  module = ModuleType("stub_dsv4_encoder")
74
77
  module.bos_token = BOS # type: ignore[attr-defined]
@@ -78,39 +81,53 @@ def _stub_encoder(reasoning_key: str = "reasoning_content") -> ModuleType:
78
81
  module.thinking_end_token = THINK_CLOSE # type: ignore[attr-defined]
79
82
  module.thinking_template = "{" + reasoning_key + "}" # type: ignore[attr-defined]
80
83
 
81
- def render_one(index: int, messages: Sequence[dict], thinking_mode: str) -> str:
84
+ def last_user_index(messages: Sequence[dict]) -> int:
85
+ for idx in range(len(messages) - 1, -1, -1):
86
+ if messages[idx].get("role") in ("user", "developer"):
87
+ return idx
88
+ return -1
89
+
90
+ def render_one(index: int, messages: Sequence[dict], thinking_mode: str, drop_thinking: bool) -> str:
82
91
  message = messages[index]
83
92
  role = message.get("role")
93
+ last_user = last_user_index(messages)
84
94
  text = ""
85
95
  if role == "user":
86
96
  text += USER + (message.get("content") or "")
87
97
  elif role == "assistant":
88
- if thinking_mode == "thinking":
98
+ # Reasoning survives only for the turn being generated (after the last user turn) or
99
+ # when dropping is off -- the encoder's `drop_thinking`, which makes the render
100
+ # non-monotonic because a completed answer's block shrinks once a later user turn
101
+ # demotes it to history.
102
+ if thinking_mode == "thinking" and (not drop_thinking or index > last_user):
89
103
  text += (message.get(reasoning_key) or "") + THINK_CLOSE
90
104
  text += message.get("content") or ""
91
105
  if not message.get("wo_eos", False):
92
106
  text += EOS
93
107
  else:
94
108
  text += message.get("content") or ""
95
- if role == "user" and index == len(messages) - 1:
96
- text += ASSISTANT + (THINK_OPEN if thinking_mode == "thinking" else THINK_CLOSE)
97
- elif role == "user":
98
- text += ASSISTANT + THINK_CLOSE
109
+ # The assistant opener rides on the user/developer turn it follows (or on the last turn,
110
+ # as the generation scaffold), never on the assistant turn itself.
111
+ is_last = index == len(messages) - 1
112
+ next_is_assistant = not is_last and messages[index + 1].get("role") == "assistant"
113
+ if role in ("user", "developer") and (is_last or next_is_assistant):
114
+ opens_thinking = thinking_mode == "thinking" and (not drop_thinking or index >= last_user)
115
+ text += ASSISTANT + (THINK_OPEN if opens_thinking else THINK_CLOSE)
99
116
  return text
100
117
 
101
118
  def encode_messages(
102
119
  messages: Sequence[dict],
103
120
  thinking_mode: str,
104
121
  context: Sequence[dict] | None = None,
105
- drop_thinking: bool = True, # noqa: ARG001 - part of the signature under test
122
+ drop_thinking: bool = True,
106
123
  add_default_bos_token: bool = True,
107
- reasoning_effort: str | None = None, # noqa: ARG001 - ditto
124
+ reasoning_effort: str | None = None, # noqa: ARG001 - part of the signature under test
108
125
  ) -> str:
109
126
  context = list(context or [])
110
127
  full = context + list(messages)
111
128
  prompt = BOS if add_default_bos_token and not context else ""
112
129
  for offset in range(len(messages)):
113
- prompt += render_one(len(context) + offset, full, thinking_mode)
130
+ prompt += render_one(len(context) + offset, full, thinking_mode, drop_thinking)
114
131
  return prompt
115
132
 
116
133
  module.encode_messages = encode_messages # type: ignore[attr-defined]
@@ -255,6 +272,30 @@ def test_continue_final_message_refuses_a_non_assistant_turn(formatter: Deepseek
255
272
  formatter.render(CHAT, continue_final_message=True)
256
273
 
257
274
 
275
+ def test_interior_assistant_opener_rides_on_its_own_block(formatter: DeepseekV4Formatter):
276
+ """A completed answer's ``<|Assistant|>`` opener belongs to the assistant, not the user.
277
+
278
+ The encoder writes the opener at the *tail* of the user turn it follows, so the raw block
279
+ split leaves it on the user turn. Left there it renders inside the previous user's bubble,
280
+ and a follow-up message makes the whole assistant scaffold appear to jump backwards a turn.
281
+ The blocks must instead read as one clean turn each.
282
+ """
283
+ conversation = [
284
+ {"role": "user", "content": "hi"},
285
+ {"role": "assistant", "content": "Hi there!"},
286
+ {"role": "user", "content": "hi 2"},
287
+ ]
288
+ rendered = formatter.render(conversation, add_generation_prompt=True, enable_thinking=False)
289
+ assert rendered.blocks == (
290
+ USER + "hi",
291
+ ASSISTANT + THINK_CLOSE + "Hi there!" + EOS,
292
+ USER + "hi 2",
293
+ )
294
+ assert rendered.suffix == ASSISTANT + THINK_CLOSE
295
+ # The move only relocates a substring across a boundary; the prompt itself is unchanged.
296
+ assert rendered.text == formatter.render(conversation, add_generation_prompt=True, enable_thinking=False).text
297
+
298
+
258
299
  def test_closed_assistant_turn_gets_no_invented_scaffold(formatter: DeepseekV4Formatter):
259
300
  """A Jinja template would open a fresh turn here; this format does not, and we follow it.
260
301
 
@@ -385,6 +426,26 @@ def test_generation_scaffold_opens_the_assistant_turn(tok: Tokenize):
385
426
  assert all(s.role == "assistant" and s.section == "header" for s in trailing)
386
427
 
387
428
 
429
+ def test_completed_assistant_opener_is_the_assistants_header_not_the_users_footer(tok: Tokenize):
430
+ """The span-level regression behind the reported bug.
431
+
432
+ On a follow-up turn the previous assistant's ``<|Assistant|></think>`` scaffold was landing
433
+ in the earlier user bubble (span metadata attributed it to the user's message), so the user
434
+ turn grew an assistant footer and the assistant turn lost its header. Each opener must be the
435
+ header of the assistant turn it introduces.
436
+ """
437
+ conversation = [
438
+ {"role": "user", "content": "hi"},
439
+ {"role": "assistant", "content": "Hi there!"},
440
+ {"role": "user", "content": "hi 2"},
441
+ ]
442
+ spans = tok.message_spans(conversation, add_generation_prompt=True, enable_thinking=False)
443
+ user0 = "".join(s.token_str for s in spans if s.message_index == 0)
444
+ assistant1_header = "".join(s.token_str for s in spans if s.message_index == 1 and s.section == "header")
445
+ assert ASSISTANT not in user0, "the assistant opener leaked into the user's bubble"
446
+ assert assistant1_header == ASSISTANT + THINK_CLOSE
447
+
448
+
388
449
  def test_prefill_spans_end_on_content(tok: Tokenize):
389
450
  """A held-open turn has no footer to find: generation continues from its last token."""
390
451
  prefill = [*CHAT, {"role": "assistant", "content": "It is "}]
@@ -398,7 +459,9 @@ def test_prefill_spans_end_on_content(tok: Tokenize):
398
459
  # --------------------------------------------------------------------------- #
399
460
 
400
461
 
401
- def _legacy_partition(tok: Tokenize, messages: list[dict[str, str]]) -> tuple[list[int], list[tuple[int, int]]]:
462
+ def _legacy_partition(
463
+ tok: Tokenize, messages: list[dict[str, str]], **template_kwargs: Any
464
+ ) -> tuple[list[int], list[tuple[int, int]]]:
402
465
  """The prefix-delta arithmetic that callers ran inline before ``message_partition``.
403
466
 
404
467
  Kept here verbatim as the reference implementation. A template-rendered model has to
@@ -409,7 +472,9 @@ def _legacy_partition(tok: Tokenize, messages: list[dict[str, str]]) -> tuple[li
409
472
  previous = 0
410
473
  full_ids: list[int] = []
411
474
  for index in range(len(messages)):
412
- current = tok.apply_chat_template(messages[: index + 1], tokenize=True, add_generation_prompt=False)
475
+ current = tok.apply_chat_template(
476
+ messages[: index + 1], tokenize=True, add_generation_prompt=False, **template_kwargs
477
+ )
413
478
  assert isinstance(current, list)
414
479
  spans.append((previous, len(current)))
415
480
  previous = len(current)
@@ -507,11 +572,15 @@ def test_the_legacy_arithmetic_would_misplace_a_code_rendered_conversation(tok:
507
572
  """Why this method exists rather than the caller keeping its inline loop.
508
573
 
509
574
  The prefix delta assumes appending a message only appends tokens. DeepSeek-V4 rewrites
510
- earlier turns once a later user turn exists (a turn stops being the *last* user turn), so
511
- the deltas land in the wrong places and the failure is silent, since they still look like
512
- a partition.
575
+ earlier turns once a later user turn exists (``drop_thinking`` strips a completed answer's
576
+ reasoning once it stops being the current turn), so the deltas land in the wrong places and
577
+ the failure is silent, since they still look like a partition. This only bites in thinking
578
+ mode, where there is reasoning to drop; in chat mode the render is monotonic and the two
579
+ agree.
513
580
  """
514
- assert tok.message_partition(MULTI_TURN) != _legacy_partition(tok, MULTI_TURN)
581
+ assert tok.message_partition(MULTI_TURN, enable_thinking=True) != _legacy_partition(
582
+ tok, MULTI_TURN, enable_thinking=True
583
+ )
515
584
 
516
585
 
517
586
  # --------------------------------------------------------------------------- #
@@ -48,6 +48,12 @@ DOC_FILES = (
48
48
  "benchmarks/README.md",
49
49
  )
50
50
 
51
+ #: The samples site, which is nothing *but* fences a reader pastes -- so it is the doc that rot
52
+ #: hurts most. Globbed rather than listed: a page there is one file with one job, and adding one
53
+ #: should not need an edit here. Its own build would not catch a rename, since it never imports
54
+ #: the package.
55
+ SAMPLE_DOCS = tuple(sorted(str(p.relative_to(ROOT)) for p in (ROOT / "visualizer-web/docs-site/docs").glob("*.md")))
56
+
51
57
  #: Submodules the docs may import from directly. The package's own ``__init__`` says these two are
52
58
  #: reachable-but-not-re-exported on purpose (the worker RPC names, and the payload codecs), so a doc
53
59
  #: that shows tier-1 integration has to name them.
@@ -85,7 +91,7 @@ def _engine_imports(tree: ast.Module) -> list[tuple[str, str]]:
85
91
 
86
92
  DOC_FENCES = [
87
93
  pytest.param(path, line, source, id=f"{path}:{line}")
88
- for path in DOC_FILES
94
+ for path in DOC_FILES + SAMPLE_DOCS
89
95
  for line, source in _fences((ROOT / path).read_text())
90
96
  ]
91
97
 
@@ -98,6 +104,11 @@ def test_the_doc_list_still_matches_the_docs_on_disk():
98
104
  assert (ROOT / path).exists(), f"{path} is listed here but does not exist"
99
105
 
100
106
 
107
+ def test_the_samples_site_was_found():
108
+ """The glob above is silent when the directory moves, which would drop every sample page."""
109
+ assert len(SAMPLE_DOCS) > 5, f"only {len(SAMPLE_DOCS)} sample pages found; did docs-site move?"
110
+
111
+
101
112
  def test_there_are_fences_to_check():
102
113
  """Guards against the regex silently matching nothing after a format change."""
103
114
  assert len(DOC_FENCES) > 15, f"only {len(DOC_FENCES)} python fences found; did the fence format change?"
@@ -32,6 +32,11 @@ from interp_engine.vllm_capture.requests import _DEMUX_MHC_HOOKS, _DEMUX_OUT_HOO
32
32
  ROOT = pathlib.Path(__file__).resolve().parent.parent
33
33
  DOCS = ROOT / "docs"
34
34
 
35
+ #: The samples site's vocabulary page. Outside ``docs/``, so nothing else in this file reaches it,
36
+ #: and it tables every point by name -- the one list a reader consults to find out what may be asked
37
+ #: for at all.
38
+ ADDRESSES_PAGE = ROOT / "visualizer-web/docs-site/docs/addresses.md"
39
+
35
40
  #: How the support table in ``docs/SUPPORTED_POINTS.md`` spells each :class:`points.VllmSupport`.
36
41
  _SUPPORT_MARKS = {
37
42
  "✅": points.VllmSupport.HOOKS,
@@ -50,6 +55,9 @@ _PROSE_COUNTS = (
50
55
  ("docs/SUPPORTED_POINTS.md", r"The (\d+) canonical points", "declared"),
51
56
  ("docs/SUPPORTED_POINTS.md", r"vLLM backend serves (\d+) of them", "served"),
52
57
  ("docs/SUPPORTED_POINTS.md", r"(\d+) of those by recompute", "recomputed"),
58
+ ("visualizer-web/docs-site/docs/addresses.md", r"The (\d+) points", "declared"),
59
+ ("visualizer-web/docs-site/docs/addresses.md", r"(\d+) on every model", "global"),
60
+ ("visualizer-web/docs-site/docs/addresses.md", r"(\d+) more that need a hyper-connection trunk", "conditional"),
53
61
  )
54
62
 
55
63
 
@@ -313,12 +321,32 @@ def test_the_support_table_doc_says_what_the_point_table_declares():
313
321
  )
314
322
 
315
323
 
324
+ def test_the_samples_page_names_every_point_and_no_others():
325
+ """``addresses.md``'s two tables, checked against the registry they transcribe.
326
+
327
+ It carries no support column -- that claim is SUPPORTED_POINTS.md's, and checked above -- so
328
+ this is the narrower assertion that the names are the point set. A point added without a row
329
+ here is one the samples site tells a reader does not exist, which is the same silent failure
330
+ as a point missing from any other consumer.
331
+ """
332
+ rows = re.findall(r"^\|([^|]*`\w+`[^|]*)\|", ADDRESSES_PAGE.read_text(), re.MULTILINE)
333
+ assert len(rows) > 20, f"the table parse found only {len(rows)} rows; did the page's format change?"
334
+
335
+ listed = {name for row in rows for name in re.findall(r"`(\w+)`", row)}
336
+ every = {p.name for p in (*points.POINTS, *points.HYPER_CONNECTION_POINTS)}
337
+ assert listed == every, (
338
+ f"missing from addresses.md: {sorted(every - listed)}; no longer a point: {sorted(listed - every)}"
339
+ )
340
+
341
+
316
342
  @pytest.mark.parametrize(("doc", "pattern", "which"), _PROSE_COUNTS)
317
343
  def test_a_point_count_stated_in_prose_matches_the_registry(doc: str, pattern: str, which: str):
318
344
  """A sentence saying "34 standardized points" is a claim the table beside it does not keep honest."""
319
345
  every = (*points.POINTS, *points.HYPER_CONNECTION_POINTS)
320
346
  expected = {
321
347
  "declared": len(every),
348
+ "global": len(points.POINTS),
349
+ "conditional": len(points.HYPER_CONNECTION_POINTS),
322
350
  "served": sum(p.vllm is not points.VllmSupport.NONE for p in every),
323
351
  "recomputed": sum(p.vllm is points.VllmSupport.RECOMPUTE for p in every),
324
352
  }[which]
@@ -136,6 +136,56 @@ class TestWhichVersionWins:
136
136
  assert release.version_key("v1.2.3") is None
137
137
 
138
138
 
139
+ class TestWhetherAChangeIsWorthAVersion:
140
+ """A version is what a downstream pin moves to, so only the imported engine earns one.
141
+
142
+ Two exclusions with different reasons: `visualizer-web/` and the validator never reach a user,
143
+ while `tests/`, `docs/`, `benchmarks/` and the README do reach one and still cannot change what
144
+ `import interp_engine` gives them.
145
+ """
146
+
147
+ PREFIXES = ["interp_engine", "tests", "docs", "benchmarks", "README.md", "pyproject.toml"]
148
+
149
+ def test_the_engine_and_its_dependency_set_are_worth_a_version(self):
150
+ assert release.changes_the_package("interp_engine/points.py", self.PREFIXES)
151
+ # The dependency table is part of what an install resolves, so pyproject counts.
152
+ assert release.changes_the_package("pyproject.toml", self.PREFIXES)
153
+
154
+ def test_the_sidecar_projects_are_not(self):
155
+ assert not release.changes_the_package("visualizer-web/data/points.ts", self.PREFIXES)
156
+ assert not release.changes_the_package("validator/comparison/score.py", self.PREFIXES)
157
+ assert not release.changes_the_package("plans/deepseek-v4-b200-bringup.md", self.PREFIXES)
158
+
159
+ def test_prose_tests_and_measurements_ship_without_being_worth_a_version(self):
160
+ # Every one of these is inside the sdist -- `is_packaged` says yes and the answer is no.
161
+ assert release.is_packaged("docs/USAGE.md", self.PREFIXES)
162
+ assert not release.changes_the_package("docs/USAGE.md", self.PREFIXES)
163
+ assert not release.changes_the_package("README.md", self.PREFIXES)
164
+ assert not release.changes_the_package("tests/test_points.py", self.PREFIXES)
165
+ assert not release.changes_the_package("benchmarks/results/gpt2.json", self.PREFIXES)
166
+
167
+ def test_a_markdown_file_beside_the_engine_is_still_only_prose(self):
168
+ assert not release.changes_the_package("interp_engine/README.md", self.PREFIXES)
169
+ assert release.changes_the_package("interp_engine/vllm_capture/mhc.py", self.PREFIXES)
170
+
171
+ def test_the_first_release_needs_no_comparison(self):
172
+ cut, why = release.release_plan("1.1.0", None, "auto", self.PREFIXES)
173
+ assert cut is True
174
+ assert "no v* tag" in why
175
+
176
+ def test_a_hand_declared_version_releases_whatever_changed(self):
177
+ # The escape hatch for a docs-only release, and for repairing a version that reached PyPI
178
+ # untagged. It is read before anything is diffed, so it cannot be overruled.
179
+ cut, why = release.release_plan("2.0.0", "1.4.3", "auto", self.PREFIXES)
180
+ assert cut is True
181
+ assert "by hand" in why
182
+
183
+ def test_a_dispatched_bump_releases_whatever_changed(self):
184
+ cut, why = release.release_plan("1.4.3", "1.4.3", "minor", self.PREFIXES)
185
+ assert cut is True
186
+ assert "by hand" in why
187
+
188
+
139
189
  class TestWhetherToUpload:
140
190
  PREFIXES = ["interp_engine", "tests", "docs", "README.md", "pyproject.toml"]
141
191
 
File without changes
File without changes