interp-engine 1.2.4__tar.gz → 1.2.6__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 (172) hide show
  1. {interp_engine-1.2.4 → interp_engine-1.2.6}/PKG-INFO +7 -1
  2. {interp_engine-1.2.4 → interp_engine-1.2.6}/README.md +6 -0
  3. {interp_engine-1.2.4 → interp_engine-1.2.6}/docs/AGENT_INTEGRATION.md +1 -1
  4. {interp_engine-1.2.4 → interp_engine-1.2.6}/docs/ARCHITECTURE_QUIRKS.md +9 -1
  5. {interp_engine-1.2.4 → interp_engine-1.2.6}/docs/USAGE.md +31 -2
  6. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/tokenize.py +56 -0
  7. {interp_engine-1.2.4 → interp_engine-1.2.6}/pyproject.toml +1 -1
  8. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_chat_formatters.py +123 -1
  9. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_chat_templates.py +31 -0
  10. {interp_engine-1.2.4 → interp_engine-1.2.6}/.gitignore +0 -0
  11. {interp_engine-1.2.4 → interp_engine-1.2.6}/LICENSE +0 -0
  12. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/README.md +0 -0
  13. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/__init__.py +0 -0
  14. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/bench_spec.py +0 -0
  15. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/cells.py +0 -0
  16. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/probe.py +0 -0
  17. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/publish.py +0 -0
  18. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/report_bench.py +0 -0
  19. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/deepseek-v4-flash-0731__eager.json +0 -0
  20. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/deepseek-v4-flash-0731__vllm-cudagraph.json +0 -0
  21. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark-cudagraph.json +0 -0
  22. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark.json +0 -0
  23. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/deepseek-v4-flash-0731__vllm-freeze.json +0 -0
  24. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/deepseek-v4-flash-0731__vllm.json +0 -0
  25. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/gemma-2-2b__eager.json +0 -0
  26. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/gemma-2-2b__vllm-cudagraph.json +0 -0
  27. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/gemma-2-2b__vllm-freeze.json +0 -0
  28. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/gemma-2-2b__vllm.json +0 -0
  29. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/llama-3.1-8b__eager.json +0 -0
  30. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/llama-3.1-8b__vllm-cudagraph.json +0 -0
  31. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/llama-3.1-8b__vllm-freeze.json +0 -0
  32. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/llama-3.1-8b__vllm.json +0 -0
  33. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/qwen3-4b__eager.json +0 -0
  34. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/qwen3-4b__vllm-cudagraph.json +0 -0
  35. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/qwen3-4b__vllm-freeze.json +0 -0
  36. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/qwen3-4b__vllm.json +0 -0
  37. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/qwen3.8-27b__eager.json +0 -0
  38. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/qwen3.8-27b__vllm-cudagraph.json +0 -0
  39. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/qwen3.8-27b__vllm-freeze.json +0 -0
  40. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results/qwen3.8-27b__vllm.json +0 -0
  41. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/results-latest.md +0 -0
  42. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/run_all.sh +0 -0
  43. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/run_bench.py +0 -0
  44. {interp_engine-1.2.4 → interp_engine-1.2.6}/benchmarks/workloads.py +0 -0
  45. {interp_engine-1.2.4 → interp_engine-1.2.6}/docs/COMPATIBILITY.md +0 -0
  46. {interp_engine-1.2.4 → interp_engine-1.2.6}/docs/ENGINE_HOOK_MAPPINGS.md +0 -0
  47. {interp_engine-1.2.4 → interp_engine-1.2.6}/docs/GRADIENTS.md +0 -0
  48. {interp_engine-1.2.4 → interp_engine-1.2.6}/docs/INTERNALS.md +0 -0
  49. {interp_engine-1.2.4 → interp_engine-1.2.6}/docs/PERFORMANCE.md +0 -0
  50. {interp_engine-1.2.4 → interp_engine-1.2.6}/docs/PORTING.md +0 -0
  51. {interp_engine-1.2.4 → interp_engine-1.2.6}/docs/README.md +0 -0
  52. {interp_engine-1.2.4 → interp_engine-1.2.6}/docs/SUPPORTED_POINTS.md +0 -0
  53. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/__init__.py +0 -0
  54. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/_loop.py +0 -0
  55. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/address.py +0 -0
  56. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/arch.py +0 -0
  57. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/attn_config.py +0 -0
  58. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/attn_scores.py +0 -0
  59. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/autograd_support.py +0 -0
  60. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/capture.py +0 -0
  61. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/chat_compose.py +0 -0
  62. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/chat_conventions.py +0 -0
  63. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/chat_formatters.py +0 -0
  64. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/cuda_preflight.py +0 -0
  65. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/dispatch.py +0 -0
  66. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/facts.py +0 -0
  67. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/hooks.py +0 -0
  68. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/lens.py +0 -0
  69. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/load.py +0 -0
  70. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/mappers.py +0 -0
  71. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/model.py +0 -0
  72. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/moe_routing.py +0 -0
  73. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/points.py +0 -0
  74. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/protocol.py +0 -0
  75. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/residual_basis.py +0 -0
  76. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/select.py +0 -0
  77. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/steer.py +0 -0
  78. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/steer_specs.py +0 -0
  79. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/sync.py +0 -0
  80. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_backend.py +0 -0
  81. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/__init__.py +0 -0
  82. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/_demux.py +0 -0
  83. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/_hooks.py +0 -0
  84. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/_payload.py +0 -0
  85. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/_tree.py +0 -0
  86. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/attn.py +0 -0
  87. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/capture.py +0 -0
  88. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/freeze.py +0 -0
  89. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/graphs.py +0 -0
  90. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/lens/__init__.py +0 -0
  91. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/lens/intervene.py +0 -0
  92. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/lens/readout.py +0 -0
  93. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/lens/unembed.py +0 -0
  94. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/mhc.py +0 -0
  95. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/native.py +0 -0
  96. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/requests.py +0 -0
  97. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_capture/steering.py +0 -0
  98. {interp_engine-1.2.4 → interp_engine-1.2.6}/interp_engine/vllm_plugin.py +0 -0
  99. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/conftest.py +0 -0
  100. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/harness.py +0 -0
  101. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/model_expectations.yaml +0 -0
  102. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/synthetic_families.py +0 -0
  103. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_address.py +0 -0
  104. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_attn_config_tripwire.py +0 -0
  105. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_attn_probs_indexing.py +0 -0
  106. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_attn_scores.py +0 -0
  107. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_attn_z_gqa.py +0 -0
  108. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_autograd_support.py +0 -0
  109. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_bench_workloads.py +0 -0
  110. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_capability_refusals.py +0 -0
  111. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_capture_addressing.py +0 -0
  112. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_chat_compose.py +0 -0
  113. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_core.py +0 -0
  114. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_cuda_preflight.py +0 -0
  115. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_doc_code_fences.py +0 -0
  116. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_eager_autograd.py +0 -0
  117. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_facts.py +0 -0
  118. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_family_points.py +0 -0
  119. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_freeze_dsv4_gpu.py +0 -0
  120. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_freeze_parity_gpu.py +0 -0
  121. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_freeze_set.py +0 -0
  122. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_freeze_warmup.py +0 -0
  123. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_gated_attn_out.py +0 -0
  124. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_head_contributions.py +0 -0
  125. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_hook_call_conventions.py +0 -0
  126. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_layer_kinds.py +0 -0
  127. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_load.py +0 -0
  128. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_logit_transform.py +0 -0
  129. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_mappers.py +0 -0
  130. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_mlp_internals.py +0 -0
  131. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_model_expectations.py +0 -0
  132. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_moe.py +0 -0
  133. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_multimodal_arch.py +0 -0
  134. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_new_models_gpu.py +0 -0
  135. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_no_chat_template.py +0 -0
  136. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_normalized_hook.py +0 -0
  137. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_packaging.py +0 -0
  138. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_parity_gpt2.py +0 -0
  139. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_per_layer_attn_dims.py +0 -0
  140. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_points_registry.py +0 -0
  141. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_protocol.py +0 -0
  142. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_published_benchmarks.py +0 -0
  143. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_qk_norm.py +0 -0
  144. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_qkv_layout.py +0 -0
  145. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_reasoning_spans.py +0 -0
  146. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_release.py +0 -0
  147. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_resid_mid.py +0 -0
  148. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_residual_basis.py +0 -0
  149. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_sandwich_norms.py +0 -0
  150. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_select.py +0 -0
  151. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_sliding_window_attn.py +0 -0
  152. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_small_models_gpu.py +0 -0
  153. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_steer_context.py +0 -0
  154. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_steer_math_parity.py +0 -0
  155. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_sync_loop.py +0 -0
  156. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_sync_parity.py +0 -0
  157. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_unified_free_functions.py +0 -0
  158. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_unresolved_families.py +0 -0
  159. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_vllm_capture_gpu.py +0 -0
  160. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_vllm_capture_scales.py +0 -0
  161. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_vllm_graph_path.py +0 -0
  162. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_vllm_graphs_on_gpu.py +0 -0
  163. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_vllm_hook_availability.py +0 -0
  164. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_vllm_hyper_connections.py +0 -0
  165. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_vllm_kv_isolation.py +0 -0
  166. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_vllm_new_points.py +0 -0
  167. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_vllm_only_families.py +0 -0
  168. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_vllm_plugin.py +0 -0
  169. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_vllm_wire_grammar.py +0 -0
  170. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_vocabulary_boundary.py +0 -0
  171. {interp_engine-1.2.4 → interp_engine-1.2.6}/tests/test_worker_lens_capture_readout.py +0 -0
  172. {interp_engine-1.2.4 → interp_engine-1.2.6}/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.2.4
3
+ Version: 1.2.6
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
@@ -37,6 +37,12 @@ Description-Content-Type: text/markdown
37
37
  <p align="center">
38
38
  🔗 <a href="https://interp-engine.org"><strong>interp-engine.org</strong></a>
39
39
  </p>
40
+ <p align="center">
41
+ <a href="https://github.com/decoderesearch/interp-engine/actions/workflows/engine-tests.yml"><img src="https://github.com/decoderesearch/interp-engine/actions/workflows/engine-tests.yml/badge.svg?branch=main" alt="CI status"></a>
42
+ <a href="https://pypi.org/project/interp-engine/"><img src="https://img.shields.io/pypi/v/interp-engine.svg" alt="PyPI version"></a>
43
+ <a href="LICENSE"><img src="https://img.shields.io/pypi/l/interp-engine.svg" alt="Apache-2.0 license"></a>
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
+ </p>
40
46
 
41
47
 
42
48
  `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.
@@ -6,6 +6,12 @@
6
6
  <p align="center">
7
7
  🔗 <a href="https://interp-engine.org"><strong>interp-engine.org</strong></a>
8
8
  </p>
9
+ <p align="center">
10
+ <a href="https://github.com/decoderesearch/interp-engine/actions/workflows/engine-tests.yml"><img src="https://github.com/decoderesearch/interp-engine/actions/workflows/engine-tests.yml/badge.svg?branch=main" alt="CI status"></a>
11
+ <a href="https://pypi.org/project/interp-engine/"><img src="https://img.shields.io/pypi/v/interp-engine.svg" alt="PyPI version"></a>
12
+ <a href="LICENSE"><img src="https://img.shields.io/pypi/l/interp-engine.svg" alt="Apache-2.0 license"></a>
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
+ </p>
9
15
 
10
16
 
11
17
  `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.
@@ -236,7 +236,7 @@ mistake you made.
236
236
  | `UnknownCoordinate` | an address carried a coordinate this version has no field for | version skew between processes — align the engine version on both |
237
237
  | `{key} fired twice in one forward pass` | one address resolved to a module invoked more than once | a re-entrant trunk; capture per-invocation or name a narrower address |
238
238
  | `Captured nothing at {missing}: the resolved module(s) did not run` | a quantized or fused kernel replaced the block's forward and computes the tensor inline | different point, or an unfused load |
239
- | `NoChatTemplateError` | the tokenizer has no chat template | `model.tok.has_chat_template()` first; do not hand-write one |
239
+ | `NoChatTemplateError` | the model has no chat format (no Jinja template and no code formatter) | `model.tok.has_chat_template()` first; do not hand-write one, and do not read `tokenizer.chat_template` — it is `None` for families that define their format in code |
240
240
  | `CapabilityUnsupported` | you asked one backend for something only the other can do | the message names the call that works; `CAPABILITIES` is the table it came from (rule 13) |
241
241
  | `... cannot run from inside a running event loop` | a sync free function or `sync_model` call from async code | `await` the method it wraps; the message names it |
242
242
  | `... needs vLLM, but vLLM is not installed` | missing extra, or a non-Linux/CUDA box | `pip install 'interp-engine[vllm]'`, or `backend="eager"`; gate on `vllm_installed()` to branch instead of catching |
@@ -38,16 +38,24 @@ in `facts.py` instead.
38
38
  | `points.py` | The canonical hook points as data: scope, width, vLLM support and the reason for each limit. Consumers (the vLLM served-point gate, the width guard, the reshape set, the docs footnotes) derive from it. | **Never for a new model** — a point is architecture-independent by construction. Only when adding a _point_, and then `tests/test_points_registry.py` tells you what else needs updating. |
39
39
  | `chat_conventions.py` | How a model structures what it **generates**: harmony markers (gpt-oss), reasoning delimiters (`REASONING_TAGS`, e.g. `<think>`/`</think>`), and generic turn-end tokens. | Only when a model introduces a _new_ reasoning delimiter pair — append one `ReasoningTags` entry. Models reusing `<think>` need no change. |
40
40
  | `chat_compose.py` | Consumes the table above to turn a generation back into messages (`compose_assistant_turns`), normalizing every family's reasoning to `<think>…</think>` in the message content. | Never for a new model — it reads `chat_conventions.py`. Only to change the _wire shape_ clients receive. |
41
+ | `chat_formatters.py` | `CODE_CHAT_FORMATS`: the few architectures that ship **no** `chat_template` and define their prompt format in Python instead (DeepSeek-V4), mapped to a loader that imports the checkpoint's own encoder. | Only when a model has no chat template at all. A model with a template needs no entry — that is the other 99%, and the default path below. |
41
42
 
42
43
  Two deliberate non-entries:
43
44
 
44
- - **Prompt-side chat structure has no table, in either direction.** Going _in_,
45
+ - **Prompt-side chat structure needs no per-family entry, in either direction.** Going _in_,
45
46
  `Tokenize.message_spans` renders through the model's real chat template and derives per-token
46
47
  role/section by diffing renders, so every family (ChatML, Gemma, Llama, harmony) works without
47
48
  per-family code. Coming back _out_, `chat_compose.py` reads only the generated text and lets the
48
49
  caller supply the prompt messages it already holds, so no code ever parses a rendered prompt back
49
50
  into messages. Between them they replaced the frontend's per-family state machines and the
50
51
  inference app's per-model response parsers.
52
+
53
+ `CODE_CHAT_FORMATS` is the one exception, and it is narrow on purpose: it holds only checkpoints
54
+ that publish **no** template, where there is no render to diff and the format exists solely as
55
+ code. Even then the entry is a _loader_, not a description — the format itself is read from the
56
+ encoder shipped beside the weights, so the table names which architectures are affected and
57
+ never what their prompts look like. Adding a model that _has_ a template here would be the
58
+ mistake the paragraph above is about.
51
59
  - **Nothing keys on a model-name _substring_.** Both tables above key on architecture class or on
52
60
  added-token-vocab membership, i.e. on **capability**. A substring match (`"llama-3" in
53
61
  name_or_path`) is the anti-pattern this structure exists to avoid: a substring is a claim about a
@@ -197,8 +197,37 @@ prompt = model.tok.apply_chat_template([{"role": "user", "content": "Hi"}])
197
197
  token_ids = model.tok.apply_chat_template([{"role": "user", "content": "Hi"}], tokenize=True)
198
198
  ```
199
199
 
200
- `apply_chat_template` raises `NoChatTemplateError` when the tokenizer has none, rather than inventing
201
- a format the model was never trained on; `model.tok.has_chat_template()` asks first.
200
+ `apply_chat_template` raises `NoChatTemplateError` when the model has no chat format at all, rather
201
+ than inventing one it was never trained on; `model.tok.has_chat_template()` asks first.
202
+
203
+ A few checkpoints ship no Jinja `chat_template` because they define their format in Python instead —
204
+ DeepSeek-V4 carries an `encoding/encoding_dsv4.py` in the repo, beside the weights, and a Jinja
205
+ template could not express its other half (`parse_message_from_completion_text`). The engine
206
+ downloads and imports that file rather than vendoring a copy of it, so those models render chat
207
+ through the same three calls above, and `has_chat_template()` says yes. Reading
208
+ `tokenizer.chat_template` directly is what gets this wrong: it is `None` for a model that renders
209
+ chat perfectly well. Loading the file is remote code execution, so it needs `trust_remote_code=True`;
210
+ without it the model still loads and chat is simply unavailable.
211
+
212
+ `model.tok.accepted_template_kwargs([...])` reports which optional controls (`enable_thinking`,
213
+ `reasoning_effort`) this model actually reads, whichever of the two renders it. Adding a family means
214
+ one entry in `chat_formatters.CODE_CHAT_FORMATS`.
215
+
216
+ To attribute *tokens* to messages there are two methods, and the difference matters. `message_spans`
217
+ gives per-token role, channel and section (`header` / `content` / `footer`), leaving the trailing
218
+ generation scaffold owned by no message — use it to read or display structure. `message_partition`
219
+ gives one contiguous `[start, end)` span per message that together cover every token, which is what
220
+ mean-pooling activations per turn needs:
221
+
222
+ ```python
223
+ token_ids, spans = model.tok.message_partition([{"role": "user", "content": "Hi"}])
224
+ per_turn = [acts[start:end].mean(0) for start, end in spans]
225
+ ```
226
+
227
+ Only `message_partition` is correct for a code-rendered model. Computing the same spans by rendering
228
+ growing message prefixes and taking length deltas assumes appending a message only appends tokens;
229
+ DeepSeek-V4 rewrites earlier turns once a later user turn exists (it drops their reasoning), so the
230
+ deltas land in the wrong places and still look like a partition.
202
231
 
203
232
  ## Capture while generating
204
233
 
@@ -297,6 +297,62 @@ class Tokenize:
297
297
  )
298
298
  )
299
299
 
300
+ def message_partition(
301
+ self,
302
+ messages: list[dict[str, str]],
303
+ **template_kwargs: Any,
304
+ ) -> tuple[list[int], list[tuple[int, int]]]:
305
+ """The closed conversation's ids, plus one contiguous ``[start, end)`` span per message.
306
+
307
+ A *partition*, which is a different contract from :meth:`message_spans`: the spans are
308
+ contiguous, cover every token, and align 1:1 with ``messages``. ``message_spans`` splits
309
+ each message into header/content/footer sections and leaves the trailing generation
310
+ scaffold owned by nobody, so a caller that mean-pools activations per turn wants this
311
+ one. Rendered closed, since a scaffold belongs to the turn nobody has written yet.
312
+
313
+ The two branches below are deliberately NOT unified. Where a code formatter renders, the
314
+ boundaries are exact, because the formatter reports its own message blocks. Where a Jinja
315
+ template does, they come from the length each render grows by as one more message is
316
+ appended -- an assumption that the template only ever appends, and also the arithmetic
317
+ every existing caller's numbers were computed with. Preserving it verbatim is the point:
318
+ a mean pooled over a span whose edge moved by one token is a different number, and these
319
+ feed persona projections that are compared across runs. So a template-rendered model is
320
+ unaffected by this method existing, and a code-rendered one gets boundaries the
321
+ prefix-delta could not have found.
322
+ """
323
+ if not messages:
324
+ return [], []
325
+
326
+ if self.formatter is not None:
327
+ rendered = self.formatter.render(
328
+ messages,
329
+ add_generation_prompt=False,
330
+ continue_final_message=False,
331
+ **template_kwargs,
332
+ )
333
+ full_ids = self._encode_rendered(rendered.text)
334
+ bounds = [0]
335
+ for j in range(1, len(messages)):
336
+ cut = _common_prefix_len(self._encode_rendered(rendered.upto(j)), full_ids)
337
+ bounds.append(max(bounds[-1], min(cut, len(full_ids))))
338
+ # The last message closes the sequence by construction (`upto(n)` is the whole
339
+ # render once the generation scaffold is off), stated rather than measured so the
340
+ # spans cover every token even if a tokenizer merges across the final cut.
341
+ bounds.append(len(full_ids))
342
+ return full_ids, list(zip(bounds, bounds[1:]))
343
+
344
+ bounds = [0]
345
+ full_ids: list[int] = []
346
+ for j in range(1, len(messages) + 1):
347
+ full_ids = self._render_ids(
348
+ messages[:j],
349
+ add_generation_prompt=False,
350
+ continue_final_message=False,
351
+ **template_kwargs,
352
+ )
353
+ bounds.append(len(full_ids))
354
+ return full_ids, list(zip(bounds, bounds[1:]))
355
+
300
356
  def message_spans(
301
357
  self,
302
358
  messages: list[dict[str, str]],
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "interp-engine"
3
- version = "1.2.4"
3
+ version = "1.2.6"
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"
@@ -29,8 +29,8 @@ import pytest
29
29
 
30
30
  from interp_engine import Tokenize
31
31
  from interp_engine.chat_formatters import (
32
- DEEPSEEK_V4_ENCODER_FILE,
33
32
  CODE_CHAT_FORMATS,
33
+ DEEPSEEK_V4_ENCODER_FILE,
34
34
  ChatFormatterUnavailable,
35
35
  DeepseekV4Formatter,
36
36
  load_deepseek_v4_formatter,
@@ -325,6 +325,7 @@ def test_accepted_template_kwargs_asks_the_formatter(tok: Tokenize):
325
325
 
326
326
  def test_accepted_template_kwargs_greps_a_real_template():
327
327
  """The Jinja path is unchanged: a name is offered only if the template mentions it."""
328
+
328
329
  class _Templated:
329
330
  chat_template = "{% if enable_thinking %}<think>{% endif %}"
330
331
  eos_token = EOS
@@ -392,6 +393,127 @@ def test_prefill_spans_end_on_content(tok: Tokenize):
392
393
  assert spans[-1].role == "assistant"
393
394
 
394
395
 
396
+ # --------------------------------------------------------------------------- #
397
+ # Per-message partition (what activation pooling indexes by)
398
+ # --------------------------------------------------------------------------- #
399
+
400
+
401
+ def _legacy_partition(tok: Tokenize, messages: list[dict[str, str]]) -> tuple[list[int], list[tuple[int, int]]]:
402
+ """The prefix-delta arithmetic that callers ran inline before ``message_partition``.
403
+
404
+ Kept here verbatim as the reference implementation. A template-rendered model has to
405
+ partition identically to this forever: the spans index activation mean-pooling, so an edge
406
+ that moves by one token silently changes a published number.
407
+ """
408
+ spans: list[tuple[int, int]] = []
409
+ previous = 0
410
+ full_ids: list[int] = []
411
+ for index in range(len(messages)):
412
+ current = tok.apply_chat_template(messages[: index + 1], tokenize=True, add_generation_prompt=False)
413
+ assert isinstance(current, list)
414
+ spans.append((previous, len(current)))
415
+ previous = len(current)
416
+ full_ids = current
417
+ return full_ids, spans
418
+
419
+
420
+ class _RecordingTokenizer(_CharTokenizer):
421
+ """A templated tokenizer that records how it was asked to render."""
422
+
423
+ chat_template = "{# present; contents never evaluated here #}"
424
+
425
+ def __init__(self):
426
+ super().__init__()
427
+ self.calls: list[tuple] = []
428
+
429
+ def apply_chat_template(
430
+ self,
431
+ messages: list[dict[str, str]],
432
+ tokenize: bool = True,
433
+ add_generation_prompt: bool = False,
434
+ continue_final_message: bool = False,
435
+ **_: Any,
436
+ ):
437
+ self.calls.append(
438
+ (tuple(m["content"] for m in messages), add_generation_prompt, continue_final_message, tokenize)
439
+ )
440
+ return list(range(1, 2 * len(messages) + 1))
441
+
442
+
443
+ def test_partition_asks_a_template_exactly_what_the_old_inline_code_asked():
444
+ """The guarantee that no template-rendered model's pooled activations move.
445
+
446
+ Asserted on the *calls* rather than on one template's output, because that covers every
447
+ template rather than whichever ones happen to be cached: same message slices, same flags,
448
+ same tokenizing path means the same ids come back, whatever the template does with them.
449
+ """
450
+ tokenizer = _RecordingTokenizer()
451
+ messages = [{"role": "user", "content": "a"}, {"role": "assistant", "content": "b"}]
452
+
453
+ full_ids, spans = Tokenize(tokenizer).message_partition(messages)
454
+
455
+ assert tokenizer.calls == [
456
+ (("a",), False, False, True),
457
+ (("a", "b"), False, False, True),
458
+ ]
459
+ assert full_ids == [1, 2, 3, 4]
460
+ assert spans == [(0, 2), (2, 4)]
461
+
462
+
463
+ def test_partition_equals_the_legacy_arithmetic_on_a_template():
464
+ tokenizer = _RecordingTokenizer()
465
+ messages = [{"role": "user", "content": "a"}, {"role": "assistant", "content": "b"}]
466
+ tok = Tokenize(tokenizer)
467
+
468
+ assert tok.message_partition(messages) == _legacy_partition(tok, messages)
469
+
470
+
471
+ def test_partition_of_no_messages_is_empty():
472
+ assert Tokenize(_RecordingTokenizer()).message_partition([]) == ([], [])
473
+
474
+
475
+ def test_partition_covers_every_token_exactly_once(tok: Tokenize):
476
+ """Contiguous, gapless, and ending on the last token — a partition, not a tagging.
477
+
478
+ Pooling reads `acts[start:end]` per message, so a gap drops activations from every mean and
479
+ an overlap double-counts them into two turns.
480
+ """
481
+ full_ids, spans = tok.message_partition(MULTI_TURN)
482
+
483
+ assert len(spans) == len(MULTI_TURN)
484
+ assert spans[0][0] == 0
485
+ assert spans[-1][1] == len(full_ids)
486
+ assert all(before[1] == after[0] for before, after in zip(spans, spans[1:]))
487
+
488
+
489
+ def test_partition_blocks_match_the_formatters_own_boundaries(tok: Tokenize, formatter: DeepseekV4Formatter):
490
+ """Where a formatter renders, the boundaries are read off it rather than inferred.
491
+
492
+ Message 0 also carries the leading scaffold (the BOS token, in ``RenderedChat.prefix``),
493
+ which is where the prefix-delta arithmetic puts it too — there is no earlier message to
494
+ attribute it to, and leaving it unowned would break the partition.
495
+ """
496
+ rendered = formatter.render(MULTI_TURN, add_generation_prompt=False)
497
+ _, spans = tok.message_partition(MULTI_TURN)
498
+
499
+ for index, (start, end) in enumerate(spans):
500
+ expected = rendered.blocks[index]
501
+ if index == 0:
502
+ expected = rendered.prefix + expected
503
+ assert end - start == len(tok.tokenizer.encode(expected)), f"message {index} span is not its own block"
504
+
505
+
506
+ def test_the_legacy_arithmetic_would_misplace_a_code_rendered_conversation(tok: Tokenize):
507
+ """Why this method exists rather than the caller keeping its inline loop.
508
+
509
+ 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.
513
+ """
514
+ assert tok.message_partition(MULTI_TURN) != _legacy_partition(tok, MULTI_TURN)
515
+
516
+
395
517
  # --------------------------------------------------------------------------- #
396
518
  # The real encoder, from the checkpoint
397
519
  # --------------------------------------------------------------------------- #
@@ -186,3 +186,34 @@ def test_reasoning_markers_present_in_tokenizer_vocab():
186
186
  """
187
187
  model = _load(QWEN_THINKING)
188
188
  assert detect_reasoning_tags(model.tokenizer.get_added_vocab().keys()) is not None
189
+
190
+
191
+ @pytest.mark.parametrize("spec", CHAT_PARAMS)
192
+ def test_message_partition_matches_the_prefix_delta_it_replaced(spec: ModelSpec):
193
+ """``message_partition`` must not move a template-rendered model's message boundaries.
194
+
195
+ Callers that mean-pool activations per turn (the persona / assistant-axis path) used to run
196
+ this arithmetic inline against the tokenizer. The spans index the pooling, so an edge that
197
+ shifts by one token quietly changes a published projection — on every deployed instruct
198
+ model, none of which needed the change. The generalisation to every template is asserted on
199
+ the render calls in ``test_chat_formatters.py``; this is the same claim against a real
200
+ template and a real BPE vocabulary.
201
+ """
202
+ model = _load(spec)
203
+ messages = [
204
+ {"role": "user", "content": "What is 2+2?"},
205
+ {"role": "assistant", "content": "Four."},
206
+ {"role": "user", "content": "And 3+3?"},
207
+ ]
208
+
209
+ spans: list[tuple[int, int]] = []
210
+ previous = 0
211
+ reference_ids: list[int] = []
212
+ for count in range(1, len(messages) + 1):
213
+ reference_ids = _coerce_ids(
214
+ model.tokenizer.apply_chat_template(messages[:count], tokenize=True, add_generation_prompt=False)
215
+ )
216
+ spans.append((previous, len(reference_ids)))
217
+ previous = len(reference_ids)
218
+
219
+ assert model.tok.message_partition(messages) == (reference_ids, spans)
File without changes
File without changes