interp-engine 1.7.2__tar.gz → 1.7.3__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 (178) hide show
  1. {interp_engine-1.7.2 → interp_engine-1.7.3}/PKG-INFO +1 -1
  2. {interp_engine-1.7.2 → interp_engine-1.7.3}/docs/USAGE.md +3 -1
  3. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/tokenize.py +78 -2
  4. {interp_engine-1.7.2 → interp_engine-1.7.3}/pyproject.toml +1 -1
  5. interp_engine-1.7.3/tests/test_injected_system_spans.py +115 -0
  6. {interp_engine-1.7.2 → interp_engine-1.7.3}/.gitignore +0 -0
  7. {interp_engine-1.7.2 → interp_engine-1.7.3}/LICENSE +0 -0
  8. {interp_engine-1.7.2 → interp_engine-1.7.3}/README.md +0 -0
  9. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/README.md +0 -0
  10. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/__init__.py +0 -0
  11. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/bench_spec.py +0 -0
  12. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/cells.py +0 -0
  13. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/probe.py +0 -0
  14. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/probe_lens_stream.py +0 -0
  15. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/publish.py +0 -0
  16. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/report_bench.py +0 -0
  17. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/deepseek-v4-flash-0731__eager.json +0 -0
  18. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/deepseek-v4-flash-0731__vllm-cudagraph.json +0 -0
  19. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/deepseek-v4-flash-0731__vllm-static.json +0 -0
  20. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/deepseek-v4-flash-0731__vllm.json +0 -0
  21. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/gemma-2-2b__eager.json +0 -0
  22. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/gemma-2-2b__vllm-cudagraph.json +0 -0
  23. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/gemma-2-2b__vllm-static.json +0 -0
  24. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/gemma-2-2b__vllm.json +0 -0
  25. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/llama-3.1-8b__eager.json +0 -0
  26. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/llama-3.1-8b__vllm-cudagraph.json +0 -0
  27. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/llama-3.1-8b__vllm-static.json +0 -0
  28. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/llama-3.1-8b__vllm.json +0 -0
  29. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/qwen3-4b__eager.json +0 -0
  30. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/qwen3-4b__vllm-cudagraph.json +0 -0
  31. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/qwen3-4b__vllm-static.json +0 -0
  32. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/qwen3-4b__vllm.json +0 -0
  33. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/qwen3.8-27b__eager.json +0 -0
  34. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/qwen3.8-27b__vllm-cudagraph.json +0 -0
  35. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/qwen3.8-27b__vllm-static.json +0 -0
  36. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results/qwen3.8-27b__vllm.json +0 -0
  37. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/results-latest.md +0 -0
  38. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/run_all.sh +0 -0
  39. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/run_bench.py +0 -0
  40. {interp_engine-1.7.2 → interp_engine-1.7.3}/benchmarks/workloads.py +0 -0
  41. {interp_engine-1.7.2 → interp_engine-1.7.3}/docs/AGENT_INTEGRATION.md +0 -0
  42. {interp_engine-1.7.2 → interp_engine-1.7.3}/docs/ARCHITECTURE_QUIRKS.md +0 -0
  43. {interp_engine-1.7.2 → interp_engine-1.7.3}/docs/COMPATIBILITY.md +0 -0
  44. {interp_engine-1.7.2 → interp_engine-1.7.3}/docs/ENGINE_HOOK_MAPPINGS.md +0 -0
  45. {interp_engine-1.7.2 → interp_engine-1.7.3}/docs/GRADIENTS.md +0 -0
  46. {interp_engine-1.7.2 → interp_engine-1.7.3}/docs/INTERNALS.md +0 -0
  47. {interp_engine-1.7.2 → interp_engine-1.7.3}/docs/PERFORMANCE.md +0 -0
  48. {interp_engine-1.7.2 → interp_engine-1.7.3}/docs/PORTING.md +0 -0
  49. {interp_engine-1.7.2 → interp_engine-1.7.3}/docs/README.md +0 -0
  50. {interp_engine-1.7.2 → interp_engine-1.7.3}/docs/SUPPORTED_POINTS.md +0 -0
  51. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/__init__.py +0 -0
  52. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/_loop.py +0 -0
  53. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/address.py +0 -0
  54. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/arch.py +0 -0
  55. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/attn_config.py +0 -0
  56. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/attn_scores.py +0 -0
  57. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/autograd_support.py +0 -0
  58. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/capture.py +0 -0
  59. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/chat_compose.py +0 -0
  60. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/chat_conventions.py +0 -0
  61. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/chat_formatters.py +0 -0
  62. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/cuda_preflight.py +0 -0
  63. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/dispatch.py +0 -0
  64. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/facts.py +0 -0
  65. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/hooks.py +0 -0
  66. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/lens.py +0 -0
  67. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/load.py +0 -0
  68. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/mappers.py +0 -0
  69. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/memory.py +0 -0
  70. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/model.py +0 -0
  71. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/moe_routing.py +0 -0
  72. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/notebook_stdout.py +0 -0
  73. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/points.py +0 -0
  74. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/protocol.py +0 -0
  75. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/residual_basis.py +0 -0
  76. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/select.py +0 -0
  77. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/steer.py +0 -0
  78. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/steer_specs.py +0 -0
  79. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/sync.py +0 -0
  80. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_backend.py +0 -0
  81. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/__init__.py +0 -0
  82. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/_demux.py +0 -0
  83. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/_hooks.py +0 -0
  84. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/_payload.py +0 -0
  85. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/_tree.py +0 -0
  86. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/attn.py +0 -0
  87. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/capture.py +0 -0
  88. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/graphs.py +0 -0
  89. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/lens/__init__.py +0 -0
  90. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/lens/intervene.py +0 -0
  91. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/lens/readout.py +0 -0
  92. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/lens/unembed.py +0 -0
  93. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/mhc.py +0 -0
  94. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/native.py +0 -0
  95. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/requests.py +0 -0
  96. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/static.py +0 -0
  97. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_capture/steering.py +0 -0
  98. {interp_engine-1.7.2 → interp_engine-1.7.3}/interp_engine/vllm_plugin.py +0 -0
  99. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/conftest.py +0 -0
  100. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/harness.py +0 -0
  101. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/model_expectations.yaml +0 -0
  102. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/synthetic_families.py +0 -0
  103. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_address.py +0 -0
  104. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_attn_config_tripwire.py +0 -0
  105. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_attn_probs_indexing.py +0 -0
  106. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_attn_scores.py +0 -0
  107. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_attn_z_gqa.py +0 -0
  108. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_autograd_support.py +0 -0
  109. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_bench_workloads.py +0 -0
  110. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_capability_refusals.py +0 -0
  111. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_capture_addressing.py +0 -0
  112. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_chat_compose.py +0 -0
  113. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_chat_formatters.py +0 -0
  114. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_chat_templates.py +0 -0
  115. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_core.py +0 -0
  116. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_cuda_preflight.py +0 -0
  117. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_doc_code_fences.py +0 -0
  118. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_eager_autograd.py +0 -0
  119. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_facts.py +0 -0
  120. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_family_points.py +0 -0
  121. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_gated_attn_out.py +0 -0
  122. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_gpu_sizer.py +0 -0
  123. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_head_contributions.py +0 -0
  124. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_hook_call_conventions.py +0 -0
  125. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_layer_kinds.py +0 -0
  126. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_load.py +0 -0
  127. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_logit_transform.py +0 -0
  128. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_mappers.py +0 -0
  129. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_memory.py +0 -0
  130. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_mlp_internals.py +0 -0
  131. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_model_expectations.py +0 -0
  132. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_moe.py +0 -0
  133. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_multimodal_arch.py +0 -0
  134. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_new_models_gpu.py +0 -0
  135. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_no_chat_template.py +0 -0
  136. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_normalized_hook.py +0 -0
  137. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_notebook_stdout.py +0 -0
  138. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_packaging.py +0 -0
  139. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_parity_gpt2.py +0 -0
  140. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_per_layer_attn_dims.py +0 -0
  141. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_points_registry.py +0 -0
  142. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_protocol.py +0 -0
  143. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_published_benchmarks.py +0 -0
  144. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_qk_norm.py +0 -0
  145. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_qkv_layout.py +0 -0
  146. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_reasoning_spans.py +0 -0
  147. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_release.py +0 -0
  148. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_resid_mid.py +0 -0
  149. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_residual_basis.py +0 -0
  150. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_sandwich_norms.py +0 -0
  151. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_select.py +0 -0
  152. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_sliding_window_attn.py +0 -0
  153. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_small_models_gpu.py +0 -0
  154. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_static_dsv4_gpu.py +0 -0
  155. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_static_parity_gpu.py +0 -0
  156. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_static_set.py +0 -0
  157. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_static_warmup.py +0 -0
  158. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_steer_context.py +0 -0
  159. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_steer_math_parity.py +0 -0
  160. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_sync_loop.py +0 -0
  161. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_sync_parity.py +0 -0
  162. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_unified_free_functions.py +0 -0
  163. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_unresolved_families.py +0 -0
  164. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vllm_capture_gpu.py +0 -0
  165. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vllm_capture_scales.py +0 -0
  166. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vllm_engine_loop.py +0 -0
  167. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vllm_graph_path.py +0 -0
  168. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vllm_graphs_on_gpu.py +0 -0
  169. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vllm_hook_availability.py +0 -0
  170. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vllm_hyper_connections.py +0 -0
  171. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vllm_kv_isolation.py +0 -0
  172. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vllm_new_points.py +0 -0
  173. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vllm_only_families.py +0 -0
  174. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vllm_plugin.py +0 -0
  175. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vllm_wire_grammar.py +0 -0
  176. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_vocabulary_boundary.py +0 -0
  177. {interp_engine-1.7.2 → interp_engine-1.7.3}/tests/test_worker_lens_capture_readout.py +0 -0
  178. {interp_engine-1.7.2 → interp_engine-1.7.3}/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.7.2
3
+ Version: 1.7.3
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
@@ -215,7 +215,9 @@ one entry in `chat_formatters.CODE_CHAT_FORMATS`.
215
215
 
216
216
  To attribute *tokens* to messages there are two methods, and the difference matters. `message_spans`
217
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`
218
+ generation scaffold owned by no message — use it to read or display structure. A system turn the
219
+ template injects on its own (Llama's knowledge-cutoff preamble, Qwen2.5's default persona) is
220
+ tagged `role="system"` with `message_index=None`, so it can be shown as its own turn. `message_partition`
219
221
  gives one contiguous `[start, end)` span per message that together cover every token, which is what
220
222
  mean-pooling activations per turn needs:
221
223
 
@@ -371,6 +371,10 @@ class Tokenize:
371
371
  - Within a message block, ``header`` / ``content`` / ``footer`` are separated by rendering
372
372
  the same message with EMPTY content and diffing (common prefix = header wrapper, common
373
373
  suffix = footer wrapper); the middle is the real content.
374
+ - A system turn the template injects on its own (Llama's knowledge-cutoff preamble, Qwen2.5's
375
+ default persona) when the caller sent no system message is tagged ``role="system"`` with
376
+ ``message_index=None``, so the frontend can show it as its own turn instead of folding it
377
+ into the first message's header. See :meth:`_injected_system_split`.
374
378
  - The trailing ``add_generation_prompt`` scaffold is attributed to the (pending) assistant
375
379
  turn as an ``assistant`` ``header`` so the assistant bubble opens before any content.
376
380
 
@@ -409,8 +413,8 @@ class Tokenize:
409
413
  prefix_end: list[int] = []
410
414
  for j in range(0, n + 1):
411
415
  if j == 0:
412
- # Empty message list: leave any leading scaffold (BOS / system preamble) as part
413
- # of message 0's block. Rendering [] would also make some templates raise.
416
+ # Empty message list: the leading scaffold (BOS / injected system turn) is split
417
+ # off message 0's block below. Rendering [] would also make some templates raise.
414
418
  prefix_end.append(0)
415
419
  continue
416
420
  if rendered is not None:
@@ -438,6 +442,18 @@ class Tokenize:
438
442
  msg_idx: list[int | None] = [None] * total
439
443
  sections: list[str] = ["scaffold"] * total
440
444
 
445
+ # A system turn the template injected itself sits at the head of message 0's block.
446
+ # Tag it as its own turn (no message index) and start message 0 after it.
447
+ injected = None
448
+ if rendered is None and n > 0:
449
+ injected = self._injected_system_split(messages, full_ids, prefix_end[1], **template_kwargs)
450
+ if injected is not None:
451
+ hdr, cnt, ftr = injected
452
+ for pos in range(hdr + cnt + ftr):
453
+ roles[pos] = "system"
454
+ sections[pos] = "header" if pos < hdr else ("content" if pos < hdr + cnt else "footer")
455
+ prefix_end[0] = hdr + cnt + ftr
456
+
441
457
  for k in range(n):
442
458
  start, end = prefix_end[k], prefix_end[k + 1]
443
459
  if end <= start:
@@ -484,6 +500,66 @@ class Tokenize:
484
500
  for pos, tid in enumerate(full_ids)
485
501
  ]
486
502
 
503
+ def _injected_system_split(
504
+ self,
505
+ messages: list[dict[str, str]],
506
+ full_ids: list[int],
507
+ block_end: int,
508
+ **template_kwargs: Any,
509
+ ) -> tuple[int, int, int] | None:
510
+ """Return ``(header_len, content_len, footer_len)`` of a system turn the template injected
511
+ at the head of ``full_ids``, or ``None`` when there is none.
512
+
513
+ Only applies when the caller sent no system message. The template is asked to render the
514
+ same first message behind an explicit system message holding a probe string, and that
515
+ render is aligned against message 0's real block ``full_ids[:block_end]``:
516
+
517
+ - The common prefix is the system header (Llama's knowledge-cutoff preamble lands here,
518
+ since the template writes it before any system content).
519
+ - The common suffix is the system footer plus the whole first-message block, which is
520
+ identical in both renders. The block's own length comes from rendering the system
521
+ message alone, so the footer is what remains.
522
+ - Whatever sits between in the real block is the injected content (Qwen2.5's default
523
+ persona); in the probe render it must be the probe itself, which is what proves the
524
+ alignment found the system slot and not some other shared token.
525
+
526
+ Templates that do not inject a system turn (Qwen3), fold the system message into the
527
+ first user turn (Gemma-3, Mistral) or reject the system role (Gemma-2) fail one of these
528
+ checks and get ``None``, leaving message 0's block as it was.
529
+ """
530
+ if messages[0].get("role") == "system":
531
+ return None
532
+ probe = "system-content-probe"
533
+ system = {"role": "system", "content": probe}
534
+ try:
535
+ sys_only = self._render_ids(
536
+ [system], add_generation_prompt=False, continue_final_message=False, **template_kwargs
537
+ )
538
+ probed = self._render_ids(
539
+ [system, messages[0]], add_generation_prompt=False, continue_final_message=False, **template_kwargs
540
+ )
541
+ except Exception: # noqa: BLE001 - some templates reject the system role
542
+ return None
543
+ if not sys_only or len(sys_only) >= len(probed) or probed[: len(sys_only)] != sys_only:
544
+ return None
545
+ block = full_ids[:block_end]
546
+ first_len = len(probed) - len(sys_only)
547
+ sys_end = len(block) - first_len
548
+ if sys_end <= 0:
549
+ return None
550
+ suffix = _common_suffix_len(probed, block)
551
+ if suffix < first_len:
552
+ return None
553
+ footer_len = suffix - first_len
554
+ header_len = min(_common_prefix_len(probed, block), sys_end - footer_len)
555
+ content_len = sys_end - footer_len - header_len
556
+ if content_len < 0:
557
+ return None
558
+ slot = probed[header_len : len(probed) - suffix]
559
+ if probe not in self.tokenizer.decode(slot, clean_up_tokenization_spaces=False):
560
+ return None
561
+ return header_len, content_len, footer_len
562
+
487
563
  def _header_footer_split(
488
564
  self,
489
565
  messages: list[dict[str, str]],
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "interp-engine"
3
- version = "1.7.2"
3
+ version = "1.7.3"
4
4
  description = "A fast, standardized, and easy to use interpretability engine."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -0,0 +1,115 @@
1
+ """A system turn the chat template injects on its own gets its own spans.
2
+
3
+ Llama 3.x writes a knowledge-cutoff preamble and Qwen2.5 a default persona into a system turn
4
+ whenever the caller sends no system message. ``Tokenize.message_spans`` tags that turn
5
+ ``role="system"`` with no message index, so a frontend can show it as its own bubble instead of
6
+ folding it into the first user message's header. Templates that inject nothing (Qwen3) or fold
7
+ the system role into the first user turn (Gemma-3) must be left exactly as they were.
8
+
9
+ Tokenizer-only: nothing here runs a forward, so the (ungated) tokenizer repos are enough.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from functools import cache
15
+
16
+ import pytest
17
+ from transformers import AutoTokenizer
18
+
19
+ from interp_engine.tokenize import Tokenize, TokenSpan
20
+
21
+ LLAMA = "unsloth/Llama-3.2-1B-Instruct"
22
+ QWEN25 = "Qwen/Qwen2.5-0.5B-Instruct"
23
+ QWEN3 = "Qwen/Qwen3-0.6B"
24
+ GEMMA3 = "unsloth/gemma-3-1b-it"
25
+
26
+ USER = [{"role": "user", "content": "What is 2+2?"}]
27
+ SYSTEM_THEN_USER = [{"role": "system", "content": "Be terse."}, *USER]
28
+
29
+
30
+ @cache
31
+ def _tok(repo: str) -> Tokenize:
32
+ try:
33
+ return Tokenize(AutoTokenizer.from_pretrained(repo))
34
+ except Exception as exc: # noqa: BLE001 - offline / uncached
35
+ pytest.skip(f"could not load tokenizer {repo}: {exc}")
36
+
37
+
38
+ def _injected(spans: list[TokenSpan]) -> list[TokenSpan]:
39
+ return [s for s in spans if s.role == "system" and s.message_index is None]
40
+
41
+
42
+ def _text(spans: list[TokenSpan]) -> str:
43
+ return "".join(s.token_str for s in spans)
44
+
45
+
46
+ @pytest.mark.parametrize("repo", [LLAMA, QWEN25])
47
+ def test_injected_system_turn_is_its_own_span_run(repo: str):
48
+ tok = _tok(repo)
49
+ spans = tok.message_spans(USER, add_generation_prompt=True)
50
+ injected = _injected(spans)
51
+ assert injected, "the template injects a system turn, so one must be tagged"
52
+ # It is one contiguous run at the very start, closed by a footer.
53
+ assert [s.position for s in injected] == list(range(len(injected)))
54
+ assert injected[0].section == "header"
55
+ assert injected[-1].section == "footer"
56
+ # Message 0 is the user turn, and it starts right after the injected turn.
57
+ user = [s for s in spans if s.message_index == 0]
58
+ assert user and all(s.role == "user" for s in user)
59
+ assert user[0].position == len(injected)
60
+ assert "2+2" in _text([s for s in user if s.section == "content"]).replace(" ", "")
61
+
62
+
63
+ def test_llama_preamble_is_the_system_header():
64
+ """Llama writes the knowledge-cutoff lines before any system content, so they are header."""
65
+ spans = _tok(LLAMA).message_spans(USER, add_generation_prompt=True)
66
+ injected = _injected(spans)
67
+ header = _text([s for s in injected if s.section == "header"])
68
+ assert "Cutting Knowledge Date" in header
69
+ assert not any(s.section == "content" for s in injected)
70
+ assert _text([s for s in injected if s.section == "footer"]) == "<|eot_id|>"
71
+
72
+
73
+ def test_qwen25_default_persona_is_the_system_content():
74
+ spans = _tok(QWEN25).message_spans(USER, add_generation_prompt=True)
75
+ injected = _injected(spans)
76
+ assert _text([s for s in injected if s.section == "header"]) == "<|im_start|>system\n"
77
+ assert _text([s for s in injected if s.section == "content"]).startswith("You are Qwen")
78
+ assert _text([s for s in injected if s.section == "footer"]) == "<|im_end|>\n"
79
+
80
+
81
+ @pytest.mark.parametrize("repo", [LLAMA, QWEN25, QWEN3, GEMMA3])
82
+ def test_explicit_system_message_is_message_zero(repo: str):
83
+ """A caller-supplied system message keeps its index; nothing is tagged as injected."""
84
+ spans = _tok(repo).message_spans(SYSTEM_THEN_USER, add_generation_prompt=True)
85
+ assert not _injected(spans)
86
+ assert any(s.message_index == 0 for s in spans)
87
+ assert any(s.message_index == 1 and s.role == "user" for s in spans)
88
+
89
+
90
+ @pytest.mark.parametrize("repo", [QWEN3, GEMMA3])
91
+ def test_templates_without_an_injected_turn_are_unchanged(repo: str):
92
+ spans = _tok(repo).message_spans(USER, add_generation_prompt=True)
93
+ assert not _injected(spans)
94
+ assert spans[0].message_index == 0
95
+ assert spans[0].role == "user"
96
+
97
+
98
+ @pytest.mark.parametrize("repo", [LLAMA, QWEN25, QWEN3, GEMMA3])
99
+ def test_spans_still_align_with_the_tokenized_prompt(repo: str):
100
+ tok = _tok(repo)
101
+ for messages in (USER, SYSTEM_THEN_USER):
102
+ spans = tok.message_spans(messages, add_generation_prompt=True)
103
+ ids = tok.apply_chat_template(messages, add_generation_prompt=True, tokenize=True)
104
+ assert [s.token_id for s in spans] == list(ids)
105
+ assert [s.position for s in spans] == list(range(len(spans)))
106
+
107
+
108
+ def test_injected_turn_survives_a_prefill():
109
+ """The split only touches the head of the sequence; an open final turn stays open."""
110
+ tok = _tok(LLAMA)
111
+ messages = [*USER, {"role": "assistant", "content": "It is"}]
112
+ spans = tok.message_spans(messages, add_generation_prompt=False, continue_final_message=True)
113
+ assert _injected(spans)
114
+ prefill = [s for s in spans if s.message_index == 1]
115
+ assert prefill and prefill[-1].section == "content"
File without changes
File without changes
File without changes