renderers 0.1.12.dev18__tar.gz → 0.1.12.dev19__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 (92) hide show
  1. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/PKG-INFO +56 -1
  2. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/README.md +55 -0
  3. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/_version.py +2 -2
  4. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/base.py +20 -0
  5. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/deepseek_v3.py +5 -0
  6. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/deepseek_v4.py +6 -0
  7. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/default.py +6 -0
  8. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/gemma4.py +6 -0
  9. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/glm45.py +6 -0
  10. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/glm5.py +8 -0
  11. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/gpt_oss.py +6 -0
  12. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/hy3.py +8 -0
  13. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/inkling.py +6 -0
  14. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/kimi_k2.py +5 -0
  15. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/kimi_k25.py +6 -0
  16. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/laguna_xs2.py +5 -0
  17. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/llama_3.py +5 -0
  18. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/minimax_m2.py +6 -0
  19. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/nemotron3.py +6 -0
  20. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/prime_qwen3.py +5 -0
  21. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/qwen3.py +6 -0
  22. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/qwen35.py +6 -0
  23. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/qwen3_vl.py +5 -0
  24. renderers-0.1.12.dev19/tests/audit_prefix_stability.py +81 -0
  25. renderers-0.1.12.dev19/tests/fixtures/prefix_stability.json +792 -0
  26. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/.github/workflows/publish-dev.yml +0 -0
  27. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/.github/workflows/publish.yml +0 -0
  28. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/.github/workflows/style.yml +0 -0
  29. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/.github/workflows/test.yml +0 -0
  30. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/.gitignore +0 -0
  31. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/.pre-commit-config.yaml +0 -0
  32. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/LICENSE +0 -0
  33. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/docs/renderer-config.md +0 -0
  34. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/examples/README.md +0 -0
  35. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/examples/sglang/multiturn_generate_sglang.py +0 -0
  36. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/examples/sglang/online_multiturn_sglang.py +0 -0
  37. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/examples/tinker/multiturn_generate_tinker.py +0 -0
  38. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/examples/transformers/multiturn_generate_transformers.py +0 -0
  39. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/examples/vllm/multiturn_generate_vllm.py +0 -0
  40. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/pyproject.toml +0 -0
  41. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/__init__.py +0 -0
  42. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/client.py +0 -0
  43. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/configs.py +0 -0
  44. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/custom.py +0 -0
  45. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/deepseek_r1.py +0 -0
  46. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/laguna_s21.py +0 -0
  47. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/parsers.py +0 -0
  48. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/parsing.py +0 -0
  49. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/qwen36.py +0 -0
  50. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/qwen38.py +0 -0
  51. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/renderers/reasoning.py +0 -0
  52. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/conftest.py +0 -0
  53. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/parity.py +0 -0
  54. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/reference_rendering.py +0 -0
  55. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_bridge.py +0 -0
  56. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_build_helpers.py +0 -0
  57. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_client.py +0 -0
  58. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_custom.py +0 -0
  59. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_deepseek_r1.py +0 -0
  60. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_deepseek_v4.py +0 -0
  61. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_disabled_thinking_stability.py +0 -0
  62. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_gemma4.py +0 -0
  63. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_glm_tool_name_validation.py +0 -0
  64. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_hy3.py +0 -0
  65. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_incremental.py +0 -0
  66. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_inkling.py +0 -0
  67. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_is_content.py +0 -0
  68. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_kimi_k25_tool_schema.py +0 -0
  69. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_laguna_m1.py +0 -0
  70. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_laguna_xs21.py +0 -0
  71. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_llama_3.py +0 -0
  72. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_load_tokenizer.py +0 -0
  73. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_message_indices.py +0 -0
  74. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_message_tool_names.py +0 -0
  75. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_multimodal.py +0 -0
  76. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_nemotron35_vlm.py +0 -0
  77. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_nemotron3_ultra.py +0 -0
  78. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_offsetless_tokenizers.py +0 -0
  79. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_parity.py +0 -0
  80. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_parse_response.py +0 -0
  81. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_parse_response_robustness.py +0 -0
  82. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_parsers.py +0 -0
  83. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_preserve_thinking.py +0 -0
  84. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_qwen35_size_coverage.py +0 -0
  85. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_qwen38.py +0 -0
  86. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_reasoning_boundaries.py +0 -0
  87. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_renderer_config.py +0 -0
  88. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_roundtrip.py +0 -0
  89. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_sampled_mask.py +0 -0
  90. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_tokens_per_message.py +0 -0
  91. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/tests/test_tool_arg_type_preservation.py +0 -0
  92. {renderers-0.1.12.dev18 → renderers-0.1.12.dev19}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: renderers
3
- Version: 0.1.12.dev18
3
+ Version: 0.1.12.dev19
4
4
  Summary: Chat template renderers — deterministic message-to-token conversion for LLM training
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -120,6 +120,8 @@ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.
120
120
 
121
121
  ```python
122
122
  class Renderer(Protocol):
123
+ @property
124
+ def is_prefix_stable(self) -> bool: ...
123
125
  def render(messages, *, tools=None, add_generation_prompt=False) -> RenderedTokens: ...
124
126
  def render_ids(messages, *, tools=None, add_generation_prompt=False) -> list[int]: ...
125
127
  def parse_response(token_ids) -> ParsedResponse: ...
@@ -132,6 +134,35 @@ class Renderer(Protocol):
132
134
  - `ParsedResponse` is `(content, reasoning_content, tool_calls)`. It scans token ids for special-token boundaries (e.g. id `151657` for `<tool_call>` on Qwen3) — a literal `"<tool_call>"` in user content tokenizes to ordinary text ids and never matches.
133
135
  - Round-trip: rendering `[user, assistant(content, reasoning, tool_calls)]`, slicing the assistant completion, and feeding it through `parse_response` returns an equivalent structured message. Tested per-renderer in `tests/test_roundtrip.py`.
134
136
 
137
+ ### Prefix stability
138
+
139
+ `renderer.is_prefix_stable` describes the constructed renderer after config and
140
+ `chat_template_kwargs` resolution. A `True` value guarantees that full renders
141
+ of assistant-terminated conversations stay token prefixes when more
142
+ user/tool/assistant messages are appended, with a fixed system/developer
143
+ preamble, tools, and `add_generation_prompt=False`. `False` means this is not
144
+ guaranteed; opaque `DefaultRenderer` templates report `False`.
145
+
146
+ The flag follows the template's emit rules, independently of the
147
+ `thinking_retention` bridge override. For example, GLM-5 with
148
+ `clear_thinking=False` is stable, while the default strips earlier reasoning.
149
+ Disabling thinking generation alone does not guarantee stability for datasets
150
+ that already contain reasoning. Other causes include GPT-OSS's final-turn
151
+ terminator and Nemotron's moving effort hint. DeepSeek V4 reports `False`
152
+ because its task/continuation message controls can rewrite the prefix; Inkling
153
+ reports `False` because reused tool-call IDs can change earlier tool-response
154
+ names, even though it retains reasoning.
155
+
156
+ SFT consumers can use this flag to warn that a single full-conversation render
157
+ may omit earlier reasoning. `build_training_sample` still returns one sample
158
+ per conversation, without expanding it into one sample per assistant turn.
159
+ The guarantee assumes unchanged message contents and deterministic tokenization
160
+ and media preprocessing. Stability does not guarantee that the template emits
161
+ every input field (some always omit reasoning), or that rendering reproduces sampled tokens. Continue
162
+ using `bridge_to_next_turn` for sampled trajectories. Custom renderers should
163
+ expose the same property; consumers supporting older/custom implementations
164
+ can conservatively use `getattr(renderer, "is_prefix_stable", False)`.
165
+
135
166
  ### `bridge_to_next_turn` (the core contract)
136
167
 
137
168
  Given `(prev_prompt_ids, prev_completion_ids)` and new environment messages, return ids for the next turn's prompt such that the result starts with `prev_prompt_ids + prev_completion_ids` byte-for-byte and continues with the new messages plus the next assistant opener. If that cannot be proven safe, return `None` and the caller falls back to a full render.
@@ -276,6 +307,30 @@ uv run pytest
276
307
 
277
308
  Round-trip parity (render → parse → original) and token-level parity against each model's independent reference encoder are tested per renderer. Most references use `apply_chat_template`; DeepSeek V4 uses its shipped Python encoder, and GPT-OSS uses Harmony.
278
309
 
310
+ Prefix-stability audit witnesses live in
311
+ [`tests/fixtures/prefix_stability.json`](tests/fixtures/prefix_stability.json).
312
+ Each case records a renderer/config, a conversation ending in an assistant,
313
+ messages to append, and the reason the original tokens stop being a prefix.
314
+ Shared scenarios keep the corpus small; paired stable settings guard the
315
+ configuration switches. `DefaultRenderer` is treated as unknown, without
316
+ inventing a witness for its opaque template.
317
+
318
+ This audit is opt-in and is not collected by the regular test suite or CI.
319
+ Run it manually when changing renderer emission rules or the stability
320
+ classification:
321
+
322
+ ```bash
323
+ uv run pytest tests/audit_prefix_stability.py -q
324
+ ```
325
+
326
+ The tests render both inputs and compare the original token IDs with the
327
+ same-length prefix of the extended render. They print the first differing
328
+ tokens and a decoded text diff on failure. These are executable witnesses,
329
+ not snapshots of token IDs that could pass without exercising the renderer.
330
+ Add a named case for each new instability mechanism or configuration branch.
331
+ A witness disproves stability; finite positive controls do not prove stability
332
+ for every possible conversation or third-party tokenizer/processor.
333
+
279
334
  ## License
280
335
 
281
336
  Licensed under the [Apache License, Version 2.0](LICENSE).
@@ -100,6 +100,8 @@ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.
100
100
 
101
101
  ```python
102
102
  class Renderer(Protocol):
103
+ @property
104
+ def is_prefix_stable(self) -> bool: ...
103
105
  def render(messages, *, tools=None, add_generation_prompt=False) -> RenderedTokens: ...
104
106
  def render_ids(messages, *, tools=None, add_generation_prompt=False) -> list[int]: ...
105
107
  def parse_response(token_ids) -> ParsedResponse: ...
@@ -112,6 +114,35 @@ class Renderer(Protocol):
112
114
  - `ParsedResponse` is `(content, reasoning_content, tool_calls)`. It scans token ids for special-token boundaries (e.g. id `151657` for `<tool_call>` on Qwen3) — a literal `"<tool_call>"` in user content tokenizes to ordinary text ids and never matches.
113
115
  - Round-trip: rendering `[user, assistant(content, reasoning, tool_calls)]`, slicing the assistant completion, and feeding it through `parse_response` returns an equivalent structured message. Tested per-renderer in `tests/test_roundtrip.py`.
114
116
 
117
+ ### Prefix stability
118
+
119
+ `renderer.is_prefix_stable` describes the constructed renderer after config and
120
+ `chat_template_kwargs` resolution. A `True` value guarantees that full renders
121
+ of assistant-terminated conversations stay token prefixes when more
122
+ user/tool/assistant messages are appended, with a fixed system/developer
123
+ preamble, tools, and `add_generation_prompt=False`. `False` means this is not
124
+ guaranteed; opaque `DefaultRenderer` templates report `False`.
125
+
126
+ The flag follows the template's emit rules, independently of the
127
+ `thinking_retention` bridge override. For example, GLM-5 with
128
+ `clear_thinking=False` is stable, while the default strips earlier reasoning.
129
+ Disabling thinking generation alone does not guarantee stability for datasets
130
+ that already contain reasoning. Other causes include GPT-OSS's final-turn
131
+ terminator and Nemotron's moving effort hint. DeepSeek V4 reports `False`
132
+ because its task/continuation message controls can rewrite the prefix; Inkling
133
+ reports `False` because reused tool-call IDs can change earlier tool-response
134
+ names, even though it retains reasoning.
135
+
136
+ SFT consumers can use this flag to warn that a single full-conversation render
137
+ may omit earlier reasoning. `build_training_sample` still returns one sample
138
+ per conversation, without expanding it into one sample per assistant turn.
139
+ The guarantee assumes unchanged message contents and deterministic tokenization
140
+ and media preprocessing. Stability does not guarantee that the template emits
141
+ every input field (some always omit reasoning), or that rendering reproduces sampled tokens. Continue
142
+ using `bridge_to_next_turn` for sampled trajectories. Custom renderers should
143
+ expose the same property; consumers supporting older/custom implementations
144
+ can conservatively use `getattr(renderer, "is_prefix_stable", False)`.
145
+
115
146
  ### `bridge_to_next_turn` (the core contract)
116
147
 
117
148
  Given `(prev_prompt_ids, prev_completion_ids)` and new environment messages, return ids for the next turn's prompt such that the result starts with `prev_prompt_ids + prev_completion_ids` byte-for-byte and continues with the new messages plus the next assistant opener. If that cannot be proven safe, return `None` and the caller falls back to a full render.
@@ -256,6 +287,30 @@ uv run pytest
256
287
 
257
288
  Round-trip parity (render → parse → original) and token-level parity against each model's independent reference encoder are tested per renderer. Most references use `apply_chat_template`; DeepSeek V4 uses its shipped Python encoder, and GPT-OSS uses Harmony.
258
289
 
290
+ Prefix-stability audit witnesses live in
291
+ [`tests/fixtures/prefix_stability.json`](tests/fixtures/prefix_stability.json).
292
+ Each case records a renderer/config, a conversation ending in an assistant,
293
+ messages to append, and the reason the original tokens stop being a prefix.
294
+ Shared scenarios keep the corpus small; paired stable settings guard the
295
+ configuration switches. `DefaultRenderer` is treated as unknown, without
296
+ inventing a witness for its opaque template.
297
+
298
+ This audit is opt-in and is not collected by the regular test suite or CI.
299
+ Run it manually when changing renderer emission rules or the stability
300
+ classification:
301
+
302
+ ```bash
303
+ uv run pytest tests/audit_prefix_stability.py -q
304
+ ```
305
+
306
+ The tests render both inputs and compare the original token IDs with the
307
+ same-length prefix of the extended render. They print the first differing
308
+ tokens and a decoded text diff on failure. These are executable witnesses,
309
+ not snapshots of token IDs that could pass without exercising the renderer.
310
+ Add a named case for each new instability mechanism or configuration branch.
311
+ A witness disproves stability; finite positive controls do not prove stability
312
+ for every possible conversation or third-party tokenizer/processor.
313
+
259
314
  ## License
260
315
 
261
316
  Licensed under the [Apache License, Version 2.0](LICENSE).
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.12.dev18'
22
- __version_tuple__ = version_tuple = (0, 1, 12, 'dev18')
21
+ __version__ = version = '0.1.12.dev19'
22
+ __version_tuple__ = version_tuple = (0, 1, 12, 'dev19')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -722,6 +722,26 @@ class ChatTemplateTokenizer(Tokenizer, Protocol):
722
722
  class Renderer(Protocol):
723
723
  """Owns message ↔ token conversion for a specific model family."""
724
724
 
725
+ @property
726
+ def is_prefix_stable(self) -> bool:
727
+ """Whether extending a conversation preserves its rendered token prefix.
728
+
729
+ For valid conversations ending in an assistant message, ``True`` means
730
+ that appending user/tool/assistant messages leaves the existing token
731
+ IDs unchanged when both renders use ``add_generation_prompt=False``.
732
+ The system/developer preamble, tools, and renderer configuration must
733
+ stay fixed. This describes full renders, not sampled completions or
734
+ the ability to bridge them; keep using ``bridge_to_next_turn`` for that.
735
+
736
+ The declaration is conservative over message contents (including
737
+ supplied reasoning even when thinking generation is disabled) and
738
+ tools. ``False`` means stability is not guaranteed, including opaque
739
+ templates. It does not mean every conversation changes. Conversely,
740
+ ``True`` does not promise to emit fields the template always ignores.
741
+ Custom renderers should declare this property from their emit rules.
742
+ """
743
+ ...
744
+
725
745
  def render(
726
746
  self,
727
747
  messages: list[Message],
@@ -56,6 +56,11 @@ class DeepSeekV3Renderer:
56
56
  stored for protocol uniformity.
57
57
  """
58
58
 
59
+ @property
60
+ def is_prefix_stable(self) -> bool:
61
+ """Whether full renders preserve completed conversation prefixes."""
62
+ return True
63
+
59
64
  #: Default typed config; the R1 subclass overrides this.
60
65
  _config_cls: type = DeepSeekV3RendererConfig
61
66
  _implied_thinking_retention = "all"
@@ -294,6 +294,12 @@ def _prepare_messages(messages: list[Message]) -> list[_LogicalMessage]:
294
294
  class DeepSeekV4Renderer:
295
295
  """Renderer for ``deepseek-ai/DeepSeek-V4-Flash-0731``."""
296
296
 
297
+ @property
298
+ def is_prefix_stable(self) -> bool:
299
+ """Whether full renders preserve completed conversation prefixes."""
300
+ # Task transitions and unterminated assistant continuations can rewrite the prefix.
301
+ return False
302
+
297
303
  _implied_thinking_retention = "tool_cycle"
298
304
 
299
305
  def __init__(
@@ -97,6 +97,12 @@ class DefaultRenderer:
97
97
  :class:`renderers.DefaultRendererConfig`).
98
98
  """
99
99
 
100
+ @property
101
+ def is_prefix_stable(self) -> bool:
102
+ """Whether full renders preserve completed conversation prefixes."""
103
+ # An opaque Jinja template cannot promise prefix stability.
104
+ return False
105
+
100
106
  def __init__(
101
107
  self,
102
108
  tokenizer: ChatTemplateTokenizer,
@@ -431,6 +431,12 @@ class _ArgumentParser:
431
431
  class Gemma4Renderer:
432
432
  """Deterministic renderer for the canonical Gemma 4 instruction models."""
433
433
 
434
+ @property
435
+ def is_prefix_stable(self) -> bool:
436
+ """Whether full renders preserve completed conversation prefixes."""
437
+ # preserve_thinking only retains tool-call reasoning, not final answers.
438
+ return False
439
+
434
440
  _config_cls = Gemma4RendererConfig
435
441
 
436
442
  def __init__(
@@ -55,6 +55,12 @@ _TOOLS_FOOTER = (
55
55
  class GLM45Renderer:
56
56
  """Deterministic message → token renderer for GLM-4.5 Air models."""
57
57
 
58
+ @property
59
+ def is_prefix_stable(self) -> bool:
60
+ """Whether full renders preserve completed conversation prefixes."""
61
+ # Historical reasoning is stripped regardless of bridge policy.
62
+ return False
63
+
58
64
  def __init__(
59
65
  self,
60
66
  tokenizer: Tokenizer,
@@ -57,6 +57,14 @@ _TOOLS_FOOTER = (
57
57
  class GLM5Renderer:
58
58
  """Deterministic message → token renderer for GLM-5 models."""
59
59
 
60
+ @property
61
+ def is_prefix_stable(self) -> bool:
62
+ """Whether full renders preserve completed conversation prefixes."""
63
+ # GLM-5.1 also changes empty thinking wrappers across user boundaries.
64
+ return not self.config.clear_thinking and not (
65
+ self.empty_think_on_last_assistant and self.config.enable_thinking
66
+ )
67
+
60
68
  # GLM-5.1 flips this on: even when the most-recent assistant has no
61
69
  # reasoning content, the template wraps it with ``<think></think>``
62
70
  # instead of just emitting ``</think>`` as a separator. Subclassed in
@@ -121,6 +121,12 @@ def _arguments_to_str(arguments: Any) -> str:
121
121
  class GptOssRenderer:
122
122
  """Deterministic message → token renderer for OpenAI gpt-oss (harmony)."""
123
123
 
124
+ @property
125
+ def is_prefix_stable(self) -> bool:
126
+ """Whether full renders preserve completed conversation prefixes."""
127
+ # The final assistant terminator changes even when auto_drop_analysis=False.
128
+ return False
129
+
124
130
  def __init__(
125
131
  self,
126
132
  tokenizer: Tokenizer,
@@ -76,6 +76,14 @@ _TOOL_RESPONSE_END = f"</tool_response{_HYTK}>"
76
76
  class Hy3Renderer:
77
77
  """Deterministic message → token renderer for Tencent Hy3 models."""
78
78
 
79
+ @property
80
+ def is_prefix_stable(self) -> bool:
81
+ """Whether full renders preserve completed conversation prefixes."""
82
+ # Require retention regardless of whether tools are supplied.
83
+ return not self.config.raw_last_assistant and (
84
+ self.config.is_training or self.config.preserved_thinking is True
85
+ )
86
+
79
87
  def __init__(
80
88
  self,
81
89
  tokenizer: Tokenizer,
@@ -173,6 +173,12 @@ class InklingRenderer:
173
173
  the :class:`~renderers.base.MultimodalRenderer` protocol.
174
174
  """
175
175
 
176
+ @property
177
+ def is_prefix_stable(self) -> bool:
178
+ """Whether full renders preserve completed conversation prefixes."""
179
+ # Reused tool-call IDs can change names resolved for earlier tool responses.
180
+ return False
181
+
176
182
  def __init__(
177
183
  self,
178
184
  tokenizer: Tokenizer,
@@ -47,6 +47,11 @@ class KimiK2Renderer:
47
47
  but have no effect on the byte-level output.
48
48
  """
49
49
 
50
+ @property
51
+ def is_prefix_stable(self) -> bool:
52
+ """Whether full renders preserve completed conversation prefixes."""
53
+ return True
54
+
50
55
  def __init__(
51
56
  self,
52
57
  tokenizer: Tokenizer,
@@ -593,6 +593,12 @@ class KimiK25Renderer:
593
593
  The tokenizer should be ``moonshotai/Kimi-K2-Instruct`` (same as K2).
594
594
  """
595
595
 
596
+ @property
597
+ def is_prefix_stable(self) -> bool:
598
+ """Whether full renders preserve completed conversation prefixes."""
599
+ # Completing a tool cycle strips its earlier reasoning, even with thinking=False.
600
+ return False
601
+
596
602
  supports_process_multimodal = True
597
603
 
598
604
  def __init__(
@@ -120,6 +120,11 @@ _TOOLS_HEADER_XS21 = (
120
120
 
121
121
 
122
122
  class LagunaXS2Renderer:
123
+ @property
124
+ def is_prefix_stable(self) -> bool:
125
+ """Whether full renders preserve completed conversation prefixes."""
126
+ return True
127
+
123
128
  def __init__(
124
129
  self,
125
130
  tokenizer: Tokenizer,
@@ -91,6 +91,11 @@ _TOOLS_IN_USER_INTRO = (
91
91
  class Llama3Renderer:
92
92
  """Deterministic message → token renderer for Llama-3.x Instruct models."""
93
93
 
94
+ @property
95
+ def is_prefix_stable(self) -> bool:
96
+ """Whether full renders preserve completed conversation prefixes."""
97
+ return True
98
+
94
99
  def __init__(
95
100
  self,
96
101
  tokenizer: Tokenizer,
@@ -58,6 +58,12 @@ _TOOLS_INSTRUCTIONS = (
58
58
  class MiniMaxM2Renderer:
59
59
  """Deterministic message → token renderer for MiniMax M2 / M2.5 models."""
60
60
 
61
+ @property
62
+ def is_prefix_stable(self) -> bool:
63
+ """Whether full renders preserve completed conversation prefixes."""
64
+ # Only assistants after the last user retain reasoning.
65
+ return False
66
+
61
67
  def __init__(
62
68
  self,
63
69
  tokenizer: Tokenizer,
@@ -118,6 +118,12 @@ class Nemotron3Renderer:
118
118
  their own discriminator and differ only by the class-level hooks here.
119
119
  """
120
120
 
121
+ @property
122
+ def is_prefix_stable(self) -> bool:
123
+ """Whether full renders preserve completed conversation prefixes."""
124
+ # An effort hint moves to the newest user, independently of thinking retention.
125
+ return not self.config.truncate_history_thinking and not self._effort_hint
126
+
121
127
  # Variant hooks (overridden by ``Nemotron3UltraRenderer``): the default
122
128
  # config to build when none is passed, and whether to use Ultra's
123
129
  # reasoning-block glue.
@@ -193,6 +193,11 @@ class _TokenBuilder:
193
193
  class PrimeQwen3Renderer:
194
194
  """Renderer for PrimeIntellect/Qwen3-0.6B and Qwen3-1.7B."""
195
195
 
196
+ @property
197
+ def is_prefix_stable(self) -> bool:
198
+ """Whether full renders preserve completed conversation prefixes."""
199
+ return True
200
+
196
201
  def __init__(
197
202
  self,
198
203
  tokenizer: Tokenizer,
@@ -59,6 +59,12 @@ _TOOLS_FOOTER = (
59
59
  class Qwen3Renderer:
60
60
  """Deterministic message → token renderer for Qwen3 models."""
61
61
 
62
+ @property
63
+ def is_prefix_stable(self) -> bool:
64
+ """Whether full renders preserve completed conversation prefixes."""
65
+ # Even with thinking disabled, supplied reasoning is stripped from history.
66
+ return False
67
+
62
68
  def __init__(
63
69
  self,
64
70
  tokenizer: Tokenizer,
@@ -126,6 +126,12 @@ def _default_enable_thinking(tokenizer) -> bool:
126
126
  class Qwen35Renderer:
127
127
  """Deterministic message → token renderer for Qwen3.5 models."""
128
128
 
129
+ @property
130
+ def is_prefix_stable(self) -> bool:
131
+ """Whether full renders preserve completed conversation prefixes."""
132
+ # Qwen3.6/3.8 can retain every thinking block through their template knob.
133
+ return bool(getattr(self.config, "preserve_thinking", False))
134
+
129
135
  supports_process_multimodal = True
130
136
  _config_cls: type = Qwen35RendererConfig
131
137
 
@@ -312,6 +312,11 @@ class Qwen3VLRenderer:
312
312
  ``thinking_retention`` still controls whether the bridge is attempted.
313
313
  """
314
314
 
315
+ @property
316
+ def is_prefix_stable(self) -> bool:
317
+ """Whether full renders preserve completed conversation prefixes."""
318
+ return True
319
+
315
320
  supports_process_multimodal = True
316
321
 
317
322
  def __init__(
@@ -0,0 +1,81 @@
1
+ """Manual audit: uv run pytest tests/audit_prefix_stability.py -q."""
2
+
3
+ import json
4
+ from difflib import unified_diff
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from conftest import _load
9
+ from renderers import DefaultRenderer, create_renderer
10
+ from renderers.base import RENDERER_REGISTRY, _populate_registry
11
+ from renderers.configs import _config_class_for
12
+
13
+ _CORPUS = json.loads(
14
+ (Path(__file__).parent / "fixtures" / "prefix_stability.json").read_text()
15
+ )
16
+
17
+
18
+ def _prefix_diff(tokenizer, before, after):
19
+ candidate = after[: len(before)]
20
+ if before == candidate:
21
+ return "No differing token: this witness now preserves the original prefix."
22
+ index = next(
23
+ (i for i, (left, right) in enumerate(zip(before, candidate)) if left != right),
24
+ min(len(before), len(candidate)),
25
+ )
26
+ diff = "\n".join(
27
+ unified_diff(
28
+ tokenizer.decode(before).splitlines(),
29
+ tokenizer.decode(candidate).splitlines(),
30
+ fromfile="original render",
31
+ tofile="same-length prefix after append",
32
+ lineterm="",
33
+ )
34
+ )
35
+ return (
36
+ f"First differing token: {index}\n"
37
+ f"Before: {before[index : index + 8]}\n"
38
+ f"After: {candidate[index : index + 8]}\n{diff}"
39
+ )
40
+
41
+
42
+ @pytest.mark.parametrize("case", _CORPUS["cases"], ids=lambda case: case["id"])
43
+ def test_prefix_stability_witness(case):
44
+ config = dict(case["config"])
45
+ name = config.pop("name")
46
+ config_cls = _config_class_for(name)
47
+ tokenizer, _ = _load(case["model"], name)
48
+ renderer = create_renderer(tokenizer, config_cls(**config))
49
+ scenario = _CORPUS["scenarios"][case["scenario"]]
50
+ messages = scenario["messages"]
51
+ assert messages[-1]["role"] == "assistant" and scenario["append"]
52
+ before = renderer.render_ids(messages, tools=scenario.get("tools"))
53
+ after = renderer.render_ids(
54
+ messages + scenario["append"], tools=scenario.get("tools")
55
+ )
56
+ extends = after[: len(before)] == before
57
+ assert renderer.is_prefix_stable is case["stable"], case["reason"]
58
+ assert extends is case["stable"], (
59
+ f"{case['reason']}\n{_prefix_diff(tokenizer, before, after)}"
60
+ )
61
+
62
+
63
+ def test_corpus_covers_builtin_renderers():
64
+ _populate_registry()
65
+ assert {case["config"]["name"] for case in _CORPUS["cases"]} | {"default"} == set(
66
+ RENDERER_REGISTRY
67
+ )
68
+ assert len({case["id"] for case in _CORPUS["cases"]}) == len(_CORPUS["cases"])
69
+ # An opaque template is unknown; inventing a counterexample would be wrong.
70
+ assert DefaultRenderer(object()).is_prefix_stable is False
71
+
72
+
73
+ def test_stability_uses_auto_resolved_template_kwargs():
74
+ tokenizer, _ = _load("Qwen/Qwen3.8-27B", "qwen3.8")
75
+ assert create_renderer(tokenizer).is_prefix_stable is True
76
+ assert (
77
+ create_renderer(
78
+ tokenizer, chat_template_kwargs={"preserve_thinking": False}
79
+ ).is_prefix_stable
80
+ is False
81
+ )