Skip to content

Commit e729baa

Browse files
authored
refactor(qwen3.5): hard-code enable_thinking default per model (#71)
1 parent 74425da commit e729baa

3 files changed

Lines changed: 76 additions & 48 deletions

File tree

renderers/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -911,8 +911,8 @@ def bridge_to_next_turn(self, *args: Any, **kwargs: Any) -> "RenderedTokens | No
911911
# ``enable_thinking=true`` (open ``<think>\n`` at the gen prompt);
912912
# the smaller 0.8B / 2B variants flip the polarity (default
913913
# ``enable_thinking=false``, empty ``<think>\n\n</think>\n\n``).
914-
# ``Qwen35Renderer`` auto-detects polarity from the tokenizer's
915-
# chat_template at construction, so all seven sizes are
914+
# ``Qwen35Renderer`` hard-codes this polarity per model
915+
# (``_ENABLE_THINKING_DEFAULTS``), so all seven sizes are
916916
# token-for-token parity-tested against their own
917917
# ``apply_chat_template`` — including with
918918
# ``add_generation_prompt=True``.

renderers/qwen35.py

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -66,39 +66,44 @@
6666
)
6767

6868

69-
def _detect_enable_thinking_default(tokenizer: PreTrainedTokenizer) -> bool:
70-
"""Probe the tokenizer's chat template to learn its ``enable_thinking``
71-
default polarity at the generation-prompt boundary.
72-
73-
The Qwen3.5 family ships two template variants that differ only in the
74-
polarity of the gated branch:
75-
76-
* Big sizes (4B / 9B / 35B-A3B / 122B-A10B / 397B-A17B) emit an open
77-
``<think>\\n`` by default and the empty ``<think>\\n\\n</think>\\n\\n``
78-
block when ``enable_thinking`` is explicitly false.
79-
* Small sizes (0.8B / 2B) flip the polarity — they emit the empty
80-
block by default and the open ``<think>\\n`` only when
81-
``enable_thinking`` is explicitly true.
82-
83-
A one-shot ``apply_chat_template`` call with no flag and a minimal
84-
user message reveals which variant is in use: the empty-block tail
85-
ends with ``</think>``, the open-think tail does not. Failing the
86-
probe (no chat_template, exotic config) falls back to the big-model
87-
default of True, which matches every entry in
88-
``MODEL_RENDERER_MAP`` that routes to ``qwen3.5`` without explicit
89-
polarity awareness.
69+
# Per-model ``enable_thinking`` default, applied when the renderer config
70+
# leaves it ``None``. The Qwen3.5 family ships two chat-template variants
71+
# that differ only in the polarity of the gated thinking branch:
72+
#
73+
# * Big sizes (4B / 9B / 35B-A3B / 122B-A10B / 397B-A17B) default
74+
# ``enable_thinking=true`` — an open ``<think>\n`` at the gen prompt.
75+
# * Small sizes (0.8B / 2B) flip it — default ``false``, emitting the
76+
# empty ``<think>\n\n</think>\n\n`` block.
77+
#
78+
# These are hard-coded (keyed by ``tokenizer.name_or_path``) rather than
79+
# probed from the live ``chat_template``: probing meant calling
80+
# ``apply_chat_template`` at construction, which pulls ``transformers`` onto
81+
# the hot path and breaks bring-your-own-tokenizer use. The values are the
82+
# ground truth pinned by ``tests/test_qwen35_size_coverage.py`` — both the
83+
# polarity assertions and byte-parity against each size's own
84+
# ``apply_chat_template``.
85+
_ENABLE_THINKING_DEFAULTS: dict[str, bool] = {
86+
"Qwen/Qwen3.5-0.8B": False,
87+
"Qwen/Qwen3.5-2B": False,
88+
"Qwen/Qwen3.5-4B": True,
89+
"Qwen/Qwen3.5-9B": True,
90+
"Qwen/Qwen3.5-35B-A3B": True,
91+
"Qwen/Qwen3.5-122B-A10B": True,
92+
"Qwen/Qwen3.5-397B-A17B": True,
93+
# Qwen3.6 extends the Qwen3.5 template; same big-size polarity.
94+
"Qwen/Qwen3.6-35B-A3B": True,
95+
}
96+
97+
98+
def _default_enable_thinking(tokenizer) -> bool:
99+
"""Hard-coded ``enable_thinking`` default for ``tokenizer``'s model.
100+
101+
Falls back to ``True`` (the big-model default, and the majority of the
102+
family) for unknown / fine-tuned checkpoints whose ``name_or_path`` isn't
103+
in ``_ENABLE_THINKING_DEFAULTS``; pass an explicit ``enable_thinking=`` to
104+
a small-size fine-tune that needs ``False``.
90105
"""
91-
try:
92-
out = tokenizer.apply_chat_template(
93-
[{"role": "user", "content": "x"}],
94-
tokenize=False,
95-
add_generation_prompt=True,
96-
)
97-
except Exception:
98-
return True
99-
if not isinstance(out, str):
100-
return True
101-
return not out.rstrip().endswith("</think>")
106+
return _ENABLE_THINKING_DEFAULTS.get(getattr(tokenizer, "name_or_path", ""), True)
102107

103108

104109
class Qwen35Renderer:
@@ -116,13 +121,13 @@ def __init__(
116121
self._tokenizer = tokenizer
117122
self._processor = processor
118123
cfg = config or type(self)._config_cls()
119-
# ``enable_thinking=None`` defers to the tokenizer's chat-template
120-
# default (Instruct → off, Thinking → on). Materialise here so
121-
# downstream reads see a concrete bool; rebind the config with
122-
# the resolved value so introspection sees the same.
124+
# ``enable_thinking=None`` defers to the model's known default (see
125+
# ``_ENABLE_THINKING_DEFAULTS``). Materialise here so downstream reads
126+
# see a concrete bool; rebind the config with the resolved value so
127+
# introspection sees the same.
123128
if cfg.enable_thinking is None:
124129
cfg = cfg.model_copy(
125-
update={"enable_thinking": _detect_enable_thinking_default(tokenizer)}
130+
update={"enable_thinking": _default_enable_thinking(tokenizer)}
126131
)
127132
self.config = cfg
128133

tests/test_qwen35_size_coverage.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55
``enable_thinking=true``); the smaller 0.8B / 2B sizes ship the polarity-
66
flipped variant (default ``enable_thinking=false`` → empty
77
``<think>\\n\\n</think>\\n\\n`` at the gen-prompt boundary). The renderer
8-
detects polarity from the tokenizer's chat_template at construction, so
9-
both variants render byte-identical to their own
10-
``apply_chat_template``.
8+
hard-codes this polarity per model (``_ENABLE_THINKING_DEFAULTS``), so
9+
both variants render byte-identical to their own ``apply_chat_template``.
1110
1211
These tests lock in (a) the exact set of Qwen3.5 sizes in the map and
1312
(b) byte parity for every one of them across representative
@@ -57,7 +56,7 @@ def test_no_other_qwen35_sizes_silently_added():
5756

5857

5958
# ---------------------------------------------------------------------------
60-
# Polarity auto-detection: 0.8B / 2B flip ``enable_thinking`` default.
59+
# Polarity defaults: 0.8B / 2B flip ``enable_thinking`` default.
6160
# ---------------------------------------------------------------------------
6261

6362

@@ -73,10 +72,10 @@ def test_no_other_qwen35_sizes_silently_added():
7372
("Qwen/Qwen3.5-397B-A17B", True),
7473
],
7574
)
76-
def test_qwen35_enable_thinking_polarity_autodetected(qwen35_model, expected_default):
77-
"""The renderer's ``_enable_thinking`` resolves to the chat template's
78-
own default when no explicit flag is passed — so big / small sizes
79-
each match their own template at the gen-prompt boundary."""
75+
def test_qwen35_enable_thinking_polarity_default(qwen35_model, expected_default):
76+
"""With no explicit flag, the renderer resolves ``enable_thinking`` from
77+
the hard-coded per-model default — so big / small sizes each match their
78+
own template at the gen-prompt boundary."""
8079
tok = load_tokenizer(qwen35_model)
8180
renderer = create_renderer(tok, Qwen35RendererConfig())
8281
assert isinstance(renderer, Qwen35Renderer)
@@ -86,6 +85,30 @@ def test_qwen35_enable_thinking_polarity_autodetected(qwen35_model, expected_def
8685
)
8786

8887

88+
def test_construction_does_not_call_apply_chat_template():
89+
"""The ``enable_thinking`` default is hard-coded per model, so building a
90+
``Qwen35Renderer`` must not probe ``apply_chat_template`` — a
91+
bring-your-own tokenizer with no chat-template support still works."""
92+
93+
class _Stub:
94+
name_or_path = "Qwen/Qwen3.5-0.8B"
95+
unk_token_id = -1
96+
97+
def convert_tokens_to_ids(self, token):
98+
# Any stable non-unk id per token; the renderer only needs the
99+
# special tokens to resolve to distinct, in-vocab ids.
100+
return abs(hash(token)) % 1_000_000 + 1
101+
102+
def apply_chat_template(self, *args, **kwargs):
103+
raise AssertionError(
104+
"apply_chat_template must not be called at construction"
105+
)
106+
107+
renderer = Qwen35Renderer(_Stub())
108+
# 0.8B is a small size → thinking defaults off, from the hard-coded table.
109+
assert renderer.config.enable_thinking is False
110+
111+
89112
# ---------------------------------------------------------------------------
90113
# Byte parity for each in-map Qwen3.5 size.
91114
# ---------------------------------------------------------------------------
@@ -146,7 +169,7 @@ def test_qwen35_size_parity_with_apply_chat_template(
146169
"""Each in-map Qwen3.5 size renders byte-identical to its own
147170
``apply_chat_template`` output. Locks in the property that lets us
148171
share ``Qwen35Renderer`` across all seven sizes — the polarity
149-
flip on 0.8B / 2B is absorbed by the constructor's auto-detect."""
172+
flip on 0.8B / 2B is absorbed by the per-model default."""
150173
tok = load_tokenizer(qwen35_model)
151174
renderer = create_renderer(tok, Qwen35RendererConfig())
152175
assert isinstance(renderer, Qwen35Renderer)

0 commit comments

Comments
 (0)