memra_tokenizer/chat.rs
1//! Minimal chat-template renderer for the Qwen3.5 / ChatML format.
2//!
3//! The model's GGUF `tokenizer.chat_template` is a large jinja template covering
4//! tools, vision, and multi-step reasoning. We do NOT ship a jinja engine; instead
5//! we reproduce the text-only system/user/assistant path of that template exactly,
6//! which is the path memra's text-in/text-out CLI uses. The reproduced behavior
7//! (verified against the dumped template):
8//!
9//! - a leading `system` turn renders `<|im_start|>system\n{content}<|im_end|>\n`
10//! - `user` -> `<|im_start|>user\n{content}<|im_end|>\n`
11//! - `assistant` -> `<|im_start|>assistant\n{content}<|im_end|>\n`
12//! - with `add_generation_prompt`, Qwen3.5 appends `<|im_start|>assistant\n<think>\n`
13//! (its default, since `enable_thinking` is undefined => the else-branch fires).
14//!
15//! `content` is trimmed (the template applies `|trim`). If the GGUF has no template
16//! we fall back to plain ChatML (no `<think>` tail).
17//!
18//! Non-qwen dialects each get their own arm, dispatched by a marker substring in the raw
19//! template: Tencent Hy3 (`hy_User`), gemma4 (`<|turn>`), and StepFun Step-3.7-Flash /
20//! arch `step35` (`render_message_content`). The step35 check must come BEFORE the qwen
21//! `<think>`-tail detection — its template contains every qwen marker, so the qwen arm would
22//! render the right generation tail on the wrong turn bodies.
23
24/// A serde-free JSON value tree, built by the server (which owns serde_json) and handed to
25/// the gemma4 tools arm. The compact gemma dialect needs argument/schema TYPE fidelity that a
26/// pre-rendered string cannot carry — a string `"21"` and a number `21` render differently
27/// (`<|"|>21<|"|>` vs `21`), a bool is `true`/`false`, a null is `None`, and mappings/sequences
28/// recurse. `Num` keeps the exact numeric text (serde_json `Number::to_string()`) so the
29/// rendered bytes match jinja's `{{ number }}` (Python `str()`), which this crate cannot
30/// reproduce from an f64 alone. qwen/step arms ignore this; they use `ToolCall::params`.
31#[derive(Debug, Clone, PartialEq)]
32pub enum Val {
33 Null,
34 Bool(bool),
35 Num(String),
36 Str(String),
37 Arr(Vec<Val>),
38 /// Insertion-ordered object; the gemma dialect `dictsort`s keys (case-insensitive, stable)
39 /// at render time, so ties keep this insertion order — matching jinja's `| dictsort`.
40 Obj(Vec<(String, Val)>),
41}
42
43/// One tool call attached to a prior assistant turn.
44/// `params` values are pre-rendered strings for the qwen/step arms (string arguments raw,
45/// everything else JSON-rendered by the caller). `args`/`id` carry the gemma4 arm's typed
46/// arguments and the OpenAI `tool_calls[].id` used to resolve tool-response names.
47#[derive(Debug, Clone, Default, PartialEq)]
48pub struct ToolCall {
49 pub name: String,
50 pub params: Vec<(String, String)>,
51 /// gemma4: typed arguments, dictsorted and dialect-rendered by the gemma arm.
52 pub args: Vec<(String, Val)>,
53 /// gemma4: the call id, matched against a following tool turn's `tool_call_id`.
54 pub id: Option<String>,
55}
56
57/// One chat turn for the tools-capable renderer (`apply_chat_template_tools`).
58/// The `reasoning`/`tool_call_id`/`tool_name`/`tool_responses` fields are read ONLY by the
59/// gemma4 arm; the qwen/step arms use `role`/`content`/`tool_calls` and leave the rest default.
60#[derive(Debug, Clone, Default, PartialEq)]
61pub struct Turn {
62 pub role: String,
63 pub content: String,
64 pub tool_calls: Vec<ToolCall>,
65 /// gemma4: assistant reasoning re-rendered as a `<|channel>thought` span (only for a
66 /// tool_calls-carrying assistant after the last user message — the template's guard).
67 pub reasoning: Option<String>,
68 /// gemma4: on a role:"tool" turn, the OpenAI `tool_call_id` used to resolve the response
69 /// name against the preceding assistant's `tool_calls[].id`.
70 pub tool_call_id: Option<String>,
71 /// gemma4: on a role:"tool" turn, the message's own `name` field (fallback when the id
72 /// does not resolve).
73 pub tool_name: Option<String>,
74 /// gemma4 native (Google) responses embedded on an assistant turn: (name, response value).
75 /// OpenAI histories leave this empty and use role:"tool" turns instead.
76 pub tool_responses: Vec<(String, Val)>,
77 /// deepseek-v4 quick-instruction task token (`action`/`query`/`authority`/`domain`/
78 /// `title`/`read_url`, encoding_dsv4 DS_TASK_SP_TOKENS). Set only by the dsv4 fixture
79 /// harness (the internal-classification heads); the OpenAI serve surface has no `task`
80 /// field, so every serve request leaves this None and every other dialect ignores it.
81 pub task: Option<String>,
82 /// deepseek-v4 per-turn tool `function` objects (encoding_dsv4 renders the tool
83 /// declaration on the message carrying them — system on the serve surface, or a developer
84 /// message in the search-pipeline fixtures). The serve path also passes request-level
85 /// tools via `tools_struct`, which the dsv4 arm folds onto the leading system turn.
86 /// Every other dialect ignores this.
87 pub tools: Vec<Val>,
88}
89
90/// Thinking control (owner directive 2026-08-07: every supported model is a thinking model,
91/// one serve surface maps to each arch's native mechanism).
92///
93/// - `Default` = the template's OWN default, byte-identical to the pre-surface render:
94/// qwen class opens `<think>\n` (thinking ON), gemma4 renders the CLOSED thought channel
95/// (its `enable_thinking | default(false)`), hy3 renders `reasoning_effort:no_think`.
96/// - `NoThink` = thinking OFF via the arch's native off-switch: qwen
97/// `enable_thinking=false` (closed `<think>\n\n</think>\n\n`), gemma4 closed thought
98/// channel, hy3 `no_think`. On step35 — whose `<think>` tail is unconditional — it clamps
99/// to the lowest effort level instead (`Reasoning: low`).
100/// - `Think` = thinking explicitly ON: qwen open `<think>\n` (same bytes as its default),
101/// gemma4 `<|think|>\n` injected into the system turn + an OPEN generation turn, hy3
102/// an open `<think:opensource>` channel at the requested effort.
103///
104/// On templates with no switch at all the non-native direction is a graceful no-op.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ThinkMode {
107 Default,
108 NoThink,
109 Think,
110}
111
112/// Which `encoding_dsv4.py` revision governs the deepseek-v4 REASONING-EFFORT ladder
113/// (0731 re-gate, 2026-08-18 — research/dsv4-template-20260818/ENCODING-DIFF.md).
114///
115/// The two shipped encodings differ ONLY here; every other rendering law (roles, tool
116/// blocks, transitions, special tokens, think-mode prefixes, parsing) is byte-identical:
117///
118/// | `reasoning_effort` | `Preview` (base repo @ 60d8d707) | `V0731` (0731 @ 7872f01b) |
119/// |--------------------|--------------------------------------|-------------------------------------|
120/// | None | no prefix | no prefix (None == "low" default) |
121/// | "low" | INVALID upstream (assert) — renders as no prefix here | no prefix |
122/// | "high" | documented NO-OP (== None) | `DS_EFFORT_ABSOLUTE_MAX` prefix |
123/// | "max" | `DS_EFFORT_ABSOLUTE_MAX` prefix | `DS_EFFORT_BEYOND_MAX` prefix |
124///
125/// The prefix (when non-empty) is injected once, before the first rendered message, in
126/// thinking mode only; chat mode never renders a prefix under either encoding.
127///
128/// DETECTION IS CONFIG-KEYED, never filename-keyed: the 0731 checkpoint added exactly four
129/// `dspark_*` keys to config.json in the same revision that remapped the ladder
130/// (`dspark_block_size`, `dspark_markov_rank`, `dspark_noise_token_id`,
131/// `dspark_target_layer_ids`); tokenizer/template files are byte-identical across the two
132/// checkpoints, so config.json is the artifact's only encoding marker. `Tokenizer::from_hf_dir`
133/// performs the census (all four -> `V0731`, none -> `Preview`, a partial set refuses to load).
134/// Callers that cannot know the revision pass `None`; rendering then REFUSES exactly the
135/// (thinking, "high"/"max") requests whose bytes differ between revisions and stays
136/// infallible everywhere the two encodings agree.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum Dsv4Encoding {
139 /// deepseek-ai/DeepSeek-V4-Flash (preview) law: {None,"high"} no-op, "max" -> absolute.
140 Preview,
141 /// DeepSeek-V4-Flash-0731 law: None/"low" no prefix, "high" -> absolute, "max" -> beyond.
142 V0731,
143}
144
145/// Render messages into the prompt string.
146///
147/// `template` is the raw GGUF chat_template (used only to decide qwen3.5-vs-plain
148/// chatml behavior — we detect the `<think>` generation tail by substring). When
149/// `None`, plain ChatML is produced.
150/// ds4f rung-3 serve finding (2026-08-22): the REAL dsv4 artifacts ship their chat
151/// dialect as CODE (`encoding/encoding_dsv4.py`) — tokenizer_config.json carries NO
152/// `chat_template` string and no chat_template.jinja exists. Every template-STRING
153/// keyed dispatch therefore never fires on the artifact we serve, and the serve-st
154/// honesty gate 400s a model whose dialect is fully defined. The artifact-level truth
155/// is the config `dspark_*` census (`Dsv4Encoding`, already detected at tokenizer
156/// load): when it is present, the dsv4 renderer IS the model's template. This entry is
157/// the plain-path dispatch on that truth; `apply_chat_template_str` keeps its exact
158/// legacy bytes for every other family.
159pub fn apply_chat_template_enc(
160 template: Option<&str>,
161 messages: &[(&str, &str)],
162 add_generation_prompt: bool,
163 dsv4_encoding: Option<Dsv4Encoding>,
164) -> Result<String, String> {
165 if dsv4_encoding.is_some() && !template.is_some_and(template_is_dsv4) {
166 let msgs: Vec<Turn> = messages
167 .iter()
168 .map(|(r, c)| Turn {
169 role: r.to_string(),
170 content: c.to_string(),
171 ..Default::default()
172 })
173 .collect();
174 return apply_dsv4_template(
175 &msgs,
176 add_generation_prompt,
177 &[],
178 ThinkMode::Default,
179 None,
180 dsv4_encoding,
181 );
182 }
183 Ok(apply_chat_template_str(
184 template,
185 messages,
186 add_generation_prompt,
187 ))
188}
189
190pub fn apply_chat_template_str(
191 template: Option<&str>,
192 messages: &[(&str, &str)],
193 add_generation_prompt: bool,
194) -> String {
195 // Tencent Hy3 (`hy_v3`): a completely different special-token dialect (no ChatML).
196 // Detected by its `hy_User` token literal; rendered by the dedicated arm below.
197 // Legacy path = the template's own default ("no_think") — byte-identical to history.
198 if template.is_some_and(|t| t.contains("hy_User")) {
199 return apply_hy3_template(messages, add_generation_prompt, "no_think");
200 }
201 // StepFun Step-3.7-Flash (arch `step35`): a ChatML *dialect* — same `<|im_start|>` framing,
202 // different everything else (see `apply_step35_template`). Detected by its
203 // `render_message_content` macro, which no other committed template defines. This check MUST
204 // precede the qwen `<think>`-tail detection below: the step35 template contains both markers,
205 // so the qwen arm would produce the right generation tail with the wrong turn bodies.
206 if template.is_some_and(|t| t.contains("render_message_content")) {
207 let turns: Vec<Turn> = messages
208 .iter()
209 .map(|(r, c)| Turn {
210 role: r.to_string(),
211 content: c.to_string(),
212 tool_calls: Vec::new(),
213 ..Default::default()
214 })
215 .collect();
216 return apply_step35_template(&turns, add_generation_prompt, &[], None);
217 }
218 // deepseek-v4 (`encoding_dsv4`): `<|User|>`/`<|Assistant|>` turn dialect with three
219 // think modes + DSML tool calls. Detected by its two structural markers (`<|Assistant|>`
220 // AND `|DSML|`). MUST precede the qwen `<think>`-tail check: a faithful dsv4 template
221 // mentions `<think>` in its tools block, and the qwen detector would otherwise fire.
222 // Legacy path = the model's own default thinking mode (thinking; see ThinkMode docs); BOS
223 // IS emitted here (encoding_dsv4 owns the BOS — tokenizer_config add_bos_token is false).
224 if template.is_some_and(template_is_dsv4) {
225 let msgs: Vec<Turn> = messages
226 .iter()
227 .map(|(r, c)| Turn {
228 role: r.to_string(),
229 content: c.to_string(),
230 ..Default::default()
231 })
232 .collect();
233 return apply_dsv4_template(
234 &msgs,
235 add_generation_prompt,
236 &[],
237 ThinkMode::Default,
238 None,
239 None,
240 )
241 .expect("dsv4 render without reasoning_effort is encoding-independent");
242 }
243 // gemma4: `<|turn>role\n{content}<turn|>\n` dialect; generation prompt appends
244 // `<|turn>model\n` + the CLOSED thought channel (`<|channel>thought\n<channel|>` — the
245 // template's enable_thinking-false default). bos comes from encode(add_special) — the
246 // template's `{{ bos_token }}` is NOT re-emitted here (double-BOS trap).
247 // Legacy path = thinking OFF (the template's `default(false)`) — byte-identical to history.
248 if template.is_some_and(|t| t.contains("<|turn>")) {
249 return apply_gemma4_template(messages, add_generation_prompt, false);
250 }
251 // qwen3.5 template emits a `<think>\n` tail on the generation prompt by default.
252 let qwen_think = template
253 .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
254 .unwrap_or(false);
255
256 let mut out = String::new();
257 for (i, (role, content)) in messages.iter().enumerate() {
258 let content = content.trim();
259 match *role {
260 "system" => {
261 // template requires system at the beginning; we render it wherever
262 // it appears at index 0 (the common case).
263 let _ = i;
264 out.push_str("<|im_start|>system\n");
265 out.push_str(content);
266 out.push_str("<|im_end|>\n");
267 }
268 "user" => {
269 out.push_str("<|im_start|>user\n");
270 out.push_str(content);
271 out.push_str("<|im_end|>\n");
272 }
273 "assistant" => {
274 out.push_str("<|im_start|>assistant\n");
275 out.push_str(content);
276 out.push_str("<|im_end|>\n");
277 }
278 other => {
279 // unsupported role in this minimal renderer; emit as a generic turn.
280 out.push_str("<|im_start|>");
281 out.push_str(other);
282 out.push('\n');
283 out.push_str(content);
284 out.push_str("<|im_end|>\n");
285 }
286 }
287 }
288
289 if add_generation_prompt {
290 out.push_str("<|im_start|>assistant\n");
291 if qwen_think {
292 out.push_str("<think>\n");
293 }
294 }
295
296 out
297}
298
299/// The fixed tool-calling instruction block of the qwen3.5/3.6-class templates. Byte-for-byte
300/// the string literal shared by ornith9b / agentworld / ref-qwen36-35b
301/// (research/onboard-ornith-20260801/templates/*.jinja) and the deployed GGUF dumps.
302const QWEN_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
303following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
304<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
305This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
306</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
307format: an inner <function=...></function> block must be nested within <tool_call></tool_call> \
308XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for \
309your function call in natural language BEFORE the function call, but NOT after\n- If there is \
310no function call available, answer the question like normal with your current knowledge and do \
311not tell the user about function calls\n</IMPORTANT>";
312
313/// Qwen3.8's REASONING-EFFORT LADDER — the two instruction sentences its chat template injects
314/// at the head of the system turn, reproduced byte-for-byte out of the shipped template
315/// (`research/reasoning-schema-20260823/qwen38-27b.chat_template.jinja`, == the served GGUF's
316/// own `tokenizer.chat_template`; the BF16 and NVFP4-Q5K mints carry the identical 9993-byte
317/// string).
318///
319/// THE DEFECT THIS EXISTS TO CLOSE (lane/reasoning-schema-20260823): the template's ladder is
320/// `reasoning_effort|default('xhigh')` over `xhigh|medium|low` (with `high` aliased to
321/// `xhigh`), but `ModelCaps::effort_levels` probed for the substring `reasoning_effort is
322/// defined` — which this template does not contain. So the level never reached the render,
323/// `reasoning_effort: low|medium|high` was accepted-and-ignored on every qwen3.8 request, AND
324/// the template's own `xhigh` default never rendered either.
325///
326/// Note which rungs carry a sentence: `xhigh` and `low` do; **`medium` deliberately renders
327/// NOTHING** (the template sets no `reasoning_instructions` for it), so `medium` is the
328/// template's own "no steering" rung, not a missing case.
329const QWEN38_EFFORT_XHIGH: &str = "Reasoning effort is set to xhigh. Please think carefully \
330through the task, validate key assumptions, consider plausible alternatives, and prioritize \
331correctness, consistency, and clarity in the final answer.";
332const QWEN38_EFFORT_LOW: &str = "Reasoning effort is set to low. Keep your thinking brief and \
333focused, moving directly to the conclusion without unnecessary elaboration.";
334
335/// Does this template carry the Qwen3.8 reasoning-effort ladder?
336///
337/// Keyed on the two instruction SENTENCES this renderer reproduces, not on the jinja control
338/// flow around them. That is the strongest form of the house's template-marker law: the probe
339/// passes only when the literal we are about to emit is the literal the template emits, so a
340/// vendor or mint that reworded a rung fails the probe and falls back to the plain qwen arm
341/// (byte-identical prompts) instead of silently rendering a sentence that model never saw.
342pub fn template_has_qwen_effort(template: &str) -> bool {
343 template.contains(QWEN38_EFFORT_XHIGH) && template.contains(QWEN38_EFFORT_LOW)
344}
345
346/// Resolve the Qwen3.8 ladder: `(think, level)` -> the instruction sentence to inject.
347///
348/// Faithful to the template's own arithmetic, in its order:
349/// 1. thinking OFF (`enable_thinking is false`) => the whole `reasoning_instructions` block
350/// is skipped, so NO sentence — a thinking-off prompt carries no effort steering.
351/// 2. `resolved = level | default('xhigh')`, then `high -> xhigh`.
352/// 3. `xhigh -> XHIGH sentence`, `low -> LOW sentence`, `medium -> '' (no sentence)`.
353/// 4. anything else => the template calls `raise_exception`, so we refuse too rather than
354/// render a rung this model was never trained on.
355fn qwen38_effort_instructions(
356 think: ThinkMode,
357 reasoning_effort: Option<&str>,
358) -> Result<&'static str, String> {
359 if think == ThinkMode::NoThink {
360 return Ok("");
361 }
362 match reasoning_effort {
363 // `None` is the template's own `default('xhigh')`; `high` is aliased to `xhigh` by the
364 // template itself, and the server's canonical table already folds xhigh/max/ultra into
365 // `high`, so these three are one rung by the model's own definition.
366 None | Some("high") | Some("xhigh") => Ok(QWEN38_EFFORT_XHIGH),
367 Some("medium") => Ok(""),
368 Some("low") => Ok(QWEN38_EFFORT_LOW),
369 Some(other) => Err(format!(
370 "reasoning effort {other:?} is not a level this chat template defines \
371 (low|medium|high; the template's own ladder is xhigh|medium|low with high \
372 aliased to xhigh)"
373 )),
374 }
375}
376
377/// Tools-capable chat rendering (serve-tools lane, 2026-08-02). Reproduces the TOOLS branch of
378/// the qwen3.5/3.6-class ChatML templates exactly (verified against the committed dumps AND the
379/// deployed GGUFs' embedded templates, byte-identical):
380///
381/// - tools present -> `<|im_start|>system\n# Tools\n\nYou have access to the following
382/// functions:\n\n<tools>` + `\n{tool json}` each + `\n</tools>` + the fixed instruction
383/// block; a leading system turn's trimmed content is appended after `\n\n`; `<|im_end|>\n`.
384/// - assistant turns with `tool_calls` -> content then `<tool_call>\n<function=NAME>\n`
385/// (+`\n\n` separator when content is non-empty; later calls separated by `\n`),
386/// `<parameter=K>\nV\n</parameter>\n` each, `</function>\n</tool_call>`, then `<|im_end|>\n`.
387/// - `tool` turns -> grouped into ONE user turn: `<|im_start|>user` opens a run of
388/// consecutive tool messages, each `\n<tool_response>\n{content}\n</tool_response>`,
389/// `<|im_end|>\n` closes the run.
390/// - generation prompt -> `<|im_start|>assistant\n` + `<think>\n` (template default) or
391/// `<think>\n\n</think>\n\n` (`ThinkMode::NoThink` = the template's `enable_thinking=false`
392/// switch; ignored when the template has no `enable_thinking`).
393///
394/// The no-tools/no-tool-turns/`Default`-think case renders byte-identically to
395/// `apply_chat_template_str` (pinned by `tools_renderer_matches_legacy_when_plain`); callers
396/// that want the hard isolation guarantee keep calling the legacy function on that path.
397/// Errors (never on the plain path): tools/tool turns on a template without a tools branch
398/// (hy3 / gemma4 / bare ChatML).
399///
400/// `reasoning_effort` is a per-dialect level STRING, never a think switch: step35 renders
401/// `Reasoning: {low|medium|high}` into the system turn (see `apply_step35_template`); hy3
402/// consumes `no_think|low|high` (medium clamps to low); deepseek-v4 resolves it through the
403/// artifact's encoding revision into the effort prompt prefix (see `Dsv4Encoding` — 0731
404/// ladder low/high/max, preview "max" only). Every other dialect ignores it (their templates
405/// have no `reasoning_effort` input), and `None` is each template's own default. The server
406/// only supplies `Some` for models whose template consumes it (`ModelCaps::effort_levels`
407/// or `ModelCaps::dsv4`), so other prompts stay byte-identical by construction, not by luck.
408pub fn apply_chat_template_tools(
409 template: Option<&str>,
410 turns: &[Turn],
411 add_generation_prompt: bool,
412 tools_json: &[String],
413 think: ThinkMode,
414 reasoning_effort: Option<&str>,
415) -> Result<String, String> {
416 // Compat entry (no structured tools, no dsv4 encoding revision): CLI bins +
417 // qwen/step/hy3 tests. The gemma4 arm needs typed tool DEFINITIONS and the dsv4 arm an
418 // encoding revision for the effort ladder, so the serve path calls `_ex` with them
419 // (a dsv4 "high"/"max" request through THIS entry refuses on the unknown revision).
420 apply_chat_template_tools_ex(
421 template,
422 turns,
423 add_generation_prompt,
424 tools_json,
425 &[],
426 think,
427 reasoning_effort,
428 None,
429 )
430}
431
432/// `apply_chat_template_tools` plus the gemma4 arm's structured tool `function` objects
433/// (`tools_struct`) and the dsv4 arm's encoding revision (`dsv4_encoding` — the effort
434/// ladder differs between the preview and 0731 checkpoints; see `Dsv4Encoding`). Every
435/// non-gemma dialect ignores `tools_struct`; every non-dsv4 dialect ignores `dsv4_encoding`.
436#[allow(clippy::too_many_arguments)]
437pub fn apply_chat_template_tools_ex(
438 template: Option<&str>,
439 turns: &[Turn],
440 add_generation_prompt: bool,
441 tools_json: &[String],
442 tools_struct: &[Val],
443 think: ThinkMode,
444 reasoning_effort: Option<&str>,
445 dsv4_encoding: Option<Dsv4Encoding>,
446) -> Result<String, String> {
447 let has_tool_features = !tools_json.is_empty()
448 || turns
449 .iter()
450 .any(|t| t.role == "tool" || !t.tool_calls.is_empty());
451 // deepseek-v4 is template-STRING-less on the real artifacts (dialect ships as
452 // encoding code) — the detected encoding revision is the dispatch truth there.
453 let is_dsv4 = dsv4_encoding.is_some() || template.is_some_and(template_is_dsv4);
454 // A template "has a tools branch" if it carries the qwen/step `<tools>` block OR the
455 // gemma4 tooluse dialect (`<|turn>` turn framing AND the `<|tool>` declaration marker)
456 // OR it is the dsv4 dialect (DSML defines a full tool protocol).
457 let tools_branch = is_dsv4 || template.is_some_and(template_has_tools_branch);
458 if has_tool_features && !tools_branch {
459 return Err("model chat template has no tools branch".into());
460 }
461 // deepseek-v4 (`encoding_dsv4`): its own dialect all the way through, tools included.
462 // Detected by its two structural markers; MUST precede the qwen/step marker checks
463 // (a faithful dsv4 template mentions `<think>` in its tools block). Renders tool
464 // DEFINITIONS (into the system turn), assistant DSML tool_calls, and role:"tool" turns
465 // merged into user `<tool_result>` blocks. ThinkMode maps onto encoding_dsv4's
466 // thinking_mode + reasoning_effort (see `apply_dsv4_template`).
467 if is_dsv4 {
468 return apply_dsv4_template(
469 turns,
470 add_generation_prompt,
471 tools_struct,
472 think,
473 reasoning_effort,
474 dsv4_encoding,
475 );
476 }
477 // step35: its own dialect all the way through, tools included (unlike hy3/gemma4, which
478 // reject tool features — step35 HAS a tools branch and it is reproduced). Must precede the
479 // qwen arm: the step35 template contains `<tools>`, `<think>` and `add_generation_prompt`,
480 // so every qwen marker check below matches it. `ThinkMode` is ignored (no `enable_thinking`
481 // in this template => `think_switch` is false => NoThink is already a documented no-op);
482 // `reasoning_effort` is this dialect's own control and is honored here.
483 if template.is_some_and(|t| t.contains("render_message_content")) {
484 return Ok(apply_step35_template(
485 turns,
486 add_generation_prompt,
487 tools_json,
488 reasoning_effort,
489 ));
490 }
491 // gemma4 TOOLUSE dialect (`<|turn>` turn framing + the `<|tool>` declaration marker):
492 // the official Google tooluse template is the rendering LAW (research/gemma4-tools-20260817
493 // /official-tooluse-template.jinja). Engages for tool DEFINITIONS, tool_calls, tool-role
494 // turns AND plain/thinking requests on this trunk. A `<|turn>` template WITHOUT `<|tool>`
495 // has no committed tools reference and falls through to the reject/plain arm below.
496 // Must precede the hy3/`<|turn>` arm (which would otherwise reject tools) and the qwen
497 // marker checks (the tooluse template carries no `<tools>`, so it would not match those).
498 if template.is_some_and(|t| t.contains("<|turn>") && t.contains("<|tool>")) {
499 // QAT-trunk variant emits a CLOSED thought channel on the thinking-off generation
500 // prompt; the official served trunk emits a bare `<|turn>model\n`. Keyed on the exact
501 // gen-prompt literal, which is present only in the QAT template's tail (verified:
502 // research/gemma4-tools-20260817 template diff).
503 let closed_tail = template.is_some_and(|t| t.contains("<|channel>thought\\n<channel|>"));
504 return Ok(apply_gemma4_tools_template(
505 turns,
506 add_generation_prompt,
507 tools_struct,
508 think == ThinkMode::Think,
509 closed_tail,
510 ));
511 }
512 if template.is_some_and(|t| t.contains("hy_User") || t.contains("<|turn>")) {
513 // hy3 / plain-gemma4 dialects: no committed tools rendering reference — reject tool
514 // features even if the raw jinja happens to mention <tools>. ThinkMode maps to each
515 // arch's native mechanism (thinking goldens, render-thinking-goldens.py):
516 // hy3 -> the template's own reasoning_effort input: no_think (its default,
517 // = ThinkMode::Default/NoThink) or low/high (open think, ThinkMode::Think
518 // at the level the caller resolved — effort carries it).
519 // gemma4 -> enable_thinking: default(false) = Default/NoThink;
520 // Think = <|think|> system token + open generation turn.
521 if has_tool_features {
522 return Err("tools are not supported on this model's chat-template dialect".into());
523 }
524 let messages: Vec<(&str, &str)> = turns
525 .iter()
526 .map(|t| (t.role.as_str(), t.content.as_str()))
527 .collect();
528 if template.is_some_and(|t| t.contains("hy_User")) {
529 // hy3's accepted set is exactly no_think|low|high; OpenAI medium clamps to low
530 // (the template has no medium level and raises on unknown strings).
531 let effort = match (think, reasoning_effort) {
532 (ThinkMode::Think, Some("high")) => "high",
533 (ThinkMode::Think, _) => "low",
534 _ => "no_think",
535 };
536 return Ok(apply_hy3_template(&messages, add_generation_prompt, effort));
537 }
538 return Ok(apply_gemma4_template(
539 &messages,
540 add_generation_prompt,
541 think == ThinkMode::Think,
542 ));
543 }
544 let qwen_think = template
545 .map(|t| t.contains("<think>") && t.contains("add_generation_prompt"))
546 .unwrap_or(false);
547 let think_switch = template.is_some_and(|t| t.contains("enable_thinking"));
548 // Qwen3.8's reasoning-effort ladder. Gated on the template carrying the two instruction
549 // sentences this renderer reproduces, so every OTHER qwen-class template (ornith15,
550 // agentworld, ref-qwen36 — binary `enable_thinking` and no ladder) renders byte-identically
551 // to before, by construction rather than by luck.
552 let effort_ladder = template.is_some_and(template_has_qwen_effort);
553 let effort_instructions = if effort_ladder {
554 qwen38_effort_instructions(think, reasoning_effort)?
555 } else {
556 ""
557 };
558
559 // LEADING SYSTEM RUN. The qwen3.8 template MERGES the whole leading run of system/developer
560 // turns into ONE system turn, joining trimmed non-empty contents with `\n`, and its body loop
561 // then refuses a system message that appears later (`System message must be at the
562 // beginning.`). memra's historical qwen arm emits one `<|im_start|>system` turn PER message,
563 // which diverges from that the moment a request carries two — a shape this server produces
564 // itself, since it normalizes OpenAI's `developer` role to `system`.
565 //
566 // The merge is scoped to LADDER templates (`qwen_effort`) on purpose, and the scope is
567 // measured rather than assumed: rendering `[system, system, user]` through the shipped jinja
568 // gives one merged turn on qwen3.8 and `raise_exception` on ornith15, so the two dialects do
569 // NOT share this law. Every non-ladder template therefore keeps its exact historical bytes.
570 let merge_leading_system = effort_ladder;
571 let n_leading_system = if merge_leading_system {
572 turns
573 .iter()
574 .take_while(|t| t.role == "system" || t.role == "developer")
575 .count()
576 } else {
577 usize::from(!tools_json.is_empty() && turns.first().is_some_and(|t| t.role == "system"))
578 };
579 let merged_system = if merge_leading_system {
580 turns[..n_leading_system]
581 .iter()
582 .map(|t| t.content.trim())
583 .filter(|c| !c.is_empty())
584 .collect::<Vec<_>>()
585 .join("\n")
586 } else {
587 turns
588 .first()
589 .filter(|_| n_leading_system > 0)
590 .map(|t| t.content.trim().to_string())
591 .unwrap_or_default()
592 };
593
594 let mut out = String::new();
595 // NO-TOOLS placement of the effort instruction (template law, verified against the shipped
596 // jinja): the sentence is PREPENDED to the merged system turn across a blank line; when the
597 // request carries no leading system content the sentence becomes a system turn of its own.
598 // Emitted here, ahead of the message loop, because that is where the template emits it.
599 if tools_json.is_empty() && merge_leading_system {
600 if !effort_instructions.is_empty() || !merged_system.is_empty() {
601 out.push_str("<|im_start|>system\n");
602 if !effort_instructions.is_empty() {
603 out.push_str(effort_instructions);
604 if !merged_system.is_empty() {
605 out.push_str("\n\n");
606 }
607 }
608 out.push_str(&merged_system);
609 out.push_str("<|im_end|>\n");
610 }
611 }
612 // Tools system header replaces the plain system turn (template law: the leading system
613 // turn's content is folded INTO the tools block).
614 if !tools_json.is_empty() {
615 out.push_str("<|im_start|>system\n");
616 // TOOLS placement: the effort sentence precedes the `# Tools` header inside the one
617 // system turn (template law: `reasoning_instructions + '\n\n'` then the header).
618 if !effort_instructions.is_empty() {
619 out.push_str(effort_instructions);
620 out.push_str("\n\n");
621 }
622 out.push_str("# Tools\n\nYou have access to the following functions:\n\n<tools>");
623 for tool in tools_json {
624 out.push('\n');
625 out.push_str(tool);
626 }
627 out.push_str("\n</tools>");
628 out.push_str(QWEN_TOOLS_INSTRUCTION);
629 if !merged_system.is_empty() {
630 out.push_str("\n\n");
631 out.push_str(&merged_system);
632 }
633 out.push_str("<|im_end|>\n");
634 }
635
636 for (i, turn) in turns.iter().enumerate() {
637 // The leading system run was already emitted (merged, or folded into the tools header).
638 if i < n_leading_system {
639 continue;
640 }
641 let content = turn.content.trim();
642 match turn.role.as_str() {
643 // A ladder template's leading system run never reaches here (merged above), so this
644 // arm is the unchanged historical path for every other dialect — and for a system
645 // message that appears AFTER a user turn, which the vendor jinja refuses outright and
646 // this renderer still passes through (pre-existing, out of this lane's scope).
647 "system" => {
648 out.push_str("<|im_start|>system\n");
649 out.push_str(content);
650 out.push_str("<|im_end|>\n");
651 }
652 "user" => {
653 out.push_str("<|im_start|>user\n");
654 out.push_str(content);
655 out.push_str("<|im_end|>\n");
656 }
657 "assistant" => {
658 out.push_str("<|im_start|>assistant\n");
659 // LADDER templates replay the prior turn's `<think>` block (vendor law:
660 // `preserve_thinking is undefined or preserve_thinking is true` — the ABSENT
661 // default is replay, `reasoning_content|trim` inside, EMPTY when the client
662 // sent none). memra historically rendered assistant turns as content only,
663 // a named gap off the vendor's bytes (see the server's preserve_thinking
664 // kwarg doc) — and the byte that kept every multi-turn conversation from
665 // ever matching a parked session's stream: the generation prompt ends in a
666 // `<think>` block, so the live stream carries it while the re-render did
667 // not. Scoped to `effort_ladder` so every other qwen-class template keeps
668 // its exact historical bytes, by construction.
669 if effort_ladder {
670 out.push_str("<think>\n");
671 out.push_str(turn.reasoning.as_deref().map(str::trim).unwrap_or(""));
672 out.push_str("\n</think>\n\n");
673 }
674 out.push_str(content);
675 for (k, call) in turn.tool_calls.iter().enumerate() {
676 if k == 0 {
677 if !content.is_empty() {
678 out.push_str("\n\n");
679 }
680 } else {
681 out.push('\n');
682 }
683 out.push_str("<tool_call>\n<function=");
684 out.push_str(&call.name);
685 out.push_str(">\n");
686 for (key, value) in &call.params {
687 out.push_str("<parameter=");
688 out.push_str(key);
689 out.push_str(">\n");
690 out.push_str(value);
691 out.push_str("\n</parameter>\n");
692 }
693 out.push_str("</function>\n</tool_call>");
694 }
695 out.push_str("<|im_end|>\n");
696 }
697 "tool" => {
698 if i == 0 || turns[i - 1].role != "tool" {
699 out.push_str("<|im_start|>user");
700 }
701 out.push_str("\n<tool_response>\n");
702 out.push_str(content);
703 out.push_str("\n</tool_response>");
704 if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
705 out.push_str("<|im_end|>\n");
706 }
707 }
708 other => {
709 // parity with the legacy renderer's generic-turn arm.
710 out.push_str("<|im_start|>");
711 out.push_str(other);
712 out.push('\n');
713 out.push_str(content);
714 out.push_str("<|im_end|>\n");
715 }
716 }
717 }
718
719 if add_generation_prompt {
720 out.push_str("<|im_start|>assistant\n");
721 if qwen_think {
722 if think == ThinkMode::NoThink && think_switch {
723 out.push_str("<think>\n\n</think>\n\n");
724 } else {
725 out.push_str("<think>\n");
726 }
727 }
728 }
729 Ok(out)
730}
731
732/// The fixed tool-calling instruction block of the StepFun `step35` template. NOT the same
733/// string as `QWEN_TOOLS_INSTRUCTION` — three differences, all load-bearing: the header says
734/// "in JSONSchema format", the nesting reminder carries literal `\n...\n` inside the
735/// `<function=...>` / `<tool_call>` examples, and the Reminder list has 2 bullets instead of 4
736/// (no "optional reasoning BEFORE the call" and no "answer normally if no function is
737/// available"). Copied byte-for-byte out of the shipped template
738/// (`research/step37-bringup-20260802/raw/chat_template.jinja`, == the GGUF's own
739/// `tokenizer.chat_template`).
740const STEP35_TOOLS_INSTRUCTION: &str = "\n\nIf you choose to call a function ONLY reply in the \
741following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n\
742<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\n\
743This is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n\
744</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified \
745format: an inner <function=...>\n...\n</function> block must be nested within <tool_call>\n\
746...\n</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>";
747
748/// StepFun Step-3.7-Flash (GGUF arch `step35`) chat template.
749///
750/// A ChatML *dialect*, not ChatML: it shares the `<|im_start|>role\n…<|im_end|>\n` frame and
751/// nothing else. Reproduced from the shipped jinja, and pinned test-by-test against goldens
752/// rendered from that jinja under jinja2 with `trim_blocks`/`lstrip_blocks` — the settings HF
753/// transformers and llama.cpp's minja both parse chat templates with
754/// (`research/step37-p2-20260806/render_step35_template.py`, goldens committed under `raw/`).
755///
756/// Where it differs from the qwen3.5/3.6 arms above — every one of these silently corrupts the
757/// prompt if the qwen arm is reused:
758///
759/// | | qwen3.5/3.6 | step35 |
760/// |---|---|---|
761/// | reasoning level | `enable_thinking` bool | `Reasoning: {low,medium,high}\n\n` prefix inside the system turn |
762/// | `<think>` tail | switchable | **unconditional** — no `enable_thinking`, so `ThinkMode::NoThink` is a no-op |
763/// | prior assistant turns | content only | turns AFTER the last real user query also carry `<think>\n{reasoning}\n</think>\n` |
764/// | tool results | grouped into a `user` turn, `\n<tool_response>\n…\n</tool_response>` | own **`tool_response`** role, `<tool_response>…</tool_response>` with NO inner newlines |
765/// | content | `\|trim`med | **not** trimmed |
766/// | tools header | `following functions:` | `following functions in JSONSchema format:` |
767/// | call separators | `\n\n` after content, `\n` between calls | **none** |
768/// | leading system + tools | appended AFTER the instruction block | folded in BEFORE `# Tools` |
769///
770/// `reasoning_effort` is the model's headline three-level control (low/medium/high per the
771/// StepFun model card). It is a parameter here rather than a `ThinkMode`: the value is a
772/// *string in the system turn*, so a bool cannot carry it. The serve path supplies it through
773/// `apply_chat_template_tools` (worker `Request::reasoning_effort`, mapped from the OpenAI
774/// `reasoning_effort` body field when `ModelCaps::effort_levels` is set); `None` — the
775/// legacy-str path and every non-step35 model — renders the template's own default
776/// (no `Reasoning:` line at all).
777///
778/// BOS is NOT emitted (the jinja's `{{bos_token}}` is dropped): memra's `encode(add_special)`
779/// prepends it from `tokenizer.ggml.add_bos_token`/`bos_token_id` — the same double-BOS trap the
780/// gemma4 arm documents.
781///
782/// ONE deliberate divergence: the jinja's body loop has no `else`, so a role outside
783/// {system, user, assistant, tool} renders as **nothing at all** — the turn silently vanishes
784/// from the prompt. memra renders it as a generic `<|im_start|>{role}\n{content}<|im_end|>\n`
785/// turn instead, matching the other arms here. A dropped turn is the worse failure, and this
786/// branch cannot fire on the serve surface: OpenAI roles are exactly system/user/assistant/tool,
787/// all four of which are reproduced byte-for-byte.
788///
789/// Not reproduced (needs data `Turn` does not carry, tracked, cannot fire from an OpenAI client):
790/// the `name == "observation"` alias that renames a non-leading `system` turn's role to
791/// `observation`, and the `<im_patch>` image-content path (this is a VLM; memra is text-only here).
792fn apply_step35_template(
793 turns: &[Turn],
794 add_generation_prompt: bool,
795 tools_json: &[String],
796 reasoning_effort: Option<&str>,
797) -> String {
798 let mut out = String::new();
799 let leading_system = turns.first().filter(|t| t.role == "system");
800
801 // --- system header. Two branches in the jinja, and the ORDER differs between them.
802 if !tools_json.is_empty() {
803 out.push_str("<|im_start|>system\n");
804 if let Some(effort) = reasoning_effort {
805 out.push_str("Reasoning: ");
806 out.push_str(effort);
807 out.push_str("\n\n");
808 }
809 if let Some(sys) = leading_system {
810 // unconditional `content + '\n\n'` — no emptiness check, unlike the qwen arm.
811 out.push_str(&sys.content);
812 out.push_str("\n\n");
813 }
814 out.push_str(
815 "# Tools\n\nYou have access to the following functions in JSONSchema \
816 format:\n\n<tools>",
817 );
818 for tool in tools_json {
819 out.push('\n');
820 out.push_str(tool);
821 }
822 out.push_str("\n</tools>");
823 out.push_str(STEP35_TOOLS_INSTRUCTION);
824 out.push_str("<|im_end|>\n");
825 } else if let Some(sys) = leading_system {
826 out.push_str("<|im_start|>system\n");
827 if let Some(effort) = reasoning_effort {
828 out.push_str("Reasoning: ");
829 out.push_str(effort);
830 out.push_str("\n\n");
831 }
832 out.push_str(&sys.content);
833 out.push_str("<|im_end|>\n");
834 } else if let Some(effort) = reasoning_effort {
835 out.push_str("<|im_start|>system\nReasoning: ");
836 out.push_str(effort);
837 out.push_str("\n\n<|im_end|>\n");
838 }
839
840 // --- last_query_index: the index of the LAST `user` turn that is a real query, i.e. whose
841 // content is not itself a `<tool_response>…</tool_response>` wrapper (a client replaying tool
842 // output as a user turn must not reset the reasoning boundary). Default len-1 when there is
843 // no such turn, exactly as the jinja's namespace initializer does.
844 let last_query_index = turns
845 .iter()
846 .enumerate()
847 .rev()
848 .find(|(_, t)| {
849 t.role == "user"
850 && !(t.content.starts_with("<tool_response>")
851 && t.content.ends_with("</tool_response>"))
852 })
853 .map(|(i, _)| i)
854 .unwrap_or(turns.len().saturating_sub(1));
855
856 for (i, turn) in turns.iter().enumerate() {
857 let content = &turn.content; // NOT trimmed: this template applies no `|trim`
858 match turn.role.as_str() {
859 // the leading system turn lives in the header above; later ones are body turns.
860 "system" if i == 0 => {}
861 "system" | "user" => {
862 out.push_str("<|im_start|>");
863 out.push_str(&turn.role);
864 out.push('\n');
865 out.push_str(content);
866 out.push_str("<|im_end|>\n");
867 }
868 "assistant" => {
869 // Split an inline `<think>…</think>` out of content, mirroring the jinja's
870 // string surgery exactly: reasoning = text before the FIRST `</think>`, with
871 // trailing newlines stripped, then everything after the LAST `<think>` in that
872 // prefix, with leading newlines stripped; body = after the LAST `</think>`,
873 // leading newlines stripped.
874 let (reasoning, body): (String, &str) = match content.find("</think>") {
875 Some(first) => {
876 let pre = content[..first].trim_end_matches('\n');
877 let pre = match pre.rfind("<think>") {
878 Some(o) => &pre[o + "<think>".len()..],
879 None => pre,
880 };
881 let last = content.rfind("</think>").unwrap();
882 (
883 pre.trim_start_matches('\n').to_string(),
884 content[last + "</think>".len()..].trim_start_matches('\n'),
885 )
886 }
887 None => (String::new(), content.as_str()),
888 };
889 out.push_str("<|im_start|>assistant\n");
890 if i > last_query_index {
891 out.push_str("<think>\n");
892 out.push_str(&reasoning);
893 out.push_str("\n</think>\n");
894 }
895 out.push_str(body);
896 // NO separator before or between calls (the qwen arm's `\n\n`/`\n` would corrupt).
897 for call in &turn.tool_calls {
898 out.push_str("<tool_call>\n<function=");
899 out.push_str(&call.name);
900 out.push_str(">\n");
901 for (key, value) in &call.params {
902 out.push_str("<parameter=");
903 out.push_str(key);
904 out.push_str(">\n");
905 out.push_str(value);
906 out.push_str("\n</parameter>\n");
907 }
908 out.push_str("</function>\n</tool_call>");
909 }
910 out.push_str("<|im_end|>\n");
911 }
912 "tool" => {
913 // own role, and consecutive tool turns share ONE `tool_response` turn.
914 if i == 0 || turns[i - 1].role != "tool" {
915 out.push_str("<|im_start|>tool_response\n");
916 }
917 out.push_str("<tool_response>");
918 out.push_str(content);
919 out.push_str("</tool_response>");
920 if i + 1 >= turns.len() || turns[i + 1].role != "tool" {
921 out.push_str("<|im_end|>\n");
922 }
923 }
924 other => {
925 // the jinja drops this turn entirely; see the divergence note above.
926 out.push_str("<|im_start|>");
927 out.push_str(other);
928 out.push('\n');
929 out.push_str(content);
930 out.push_str("<|im_end|>\n");
931 }
932 }
933 }
934
935 if add_generation_prompt {
936 out.push_str("<|im_start|>assistant\n<think>\n");
937 }
938 out
939}
940
941/// Text-only reproduction of the Hy3 `chat_template.jinja` (no tools, no `is_training`).
942/// `effort` is the template's own `reasoning_effort` input — `"no_think"` / `"low"` /
943/// `"high"`, its full accepted set (the jinja `raise_exception`s on anything else; undefined
944/// defaults to `'no_think'`, so callers with no opinion pass `"no_think"`):
945/// - `{bos}{system…}<|reasoning_mode:opensource|>reasoning_effort:{effort}` header
946/// (system turns concatenate into the header, before any user turn);
947/// - `user` -> `<|hy_User:opensource|>{content}`
948/// - `assistant` -> `<|hy_Assistant:opensource|><think:opensource></think:opensource>{content}<|hy_eos:opensource|>`
949/// (non-last turns; history turns render CLOSED think at every effort — the template
950/// opens only turns past `last_user_index`, and OpenAI history carries no reasoning);
951/// - generation prompt: `<|hy_Assistant:opensource|><think:opensource></think:opensource>`
952/// at no_think, `…<think:opensource>` (OPEN think) at low/high.
953/// Content is NOT trimmed (the Hy3 template applies no `|trim`). Goldens: rendered from the
954/// pinned tencent/Hy3 template (sha 7fc351fe…, snapshot 716aa724) by
955/// `research/step-sku-20260807/render-thinking-goldens.py`.
956fn apply_hy3_template(
957 messages: &[(&str, &str)],
958 add_generation_prompt: bool,
959 effort: &str,
960) -> String {
961 const BOS: &str = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>";
962 const USER: &str = "<\u{ff5c}hy_User:opensource\u{ff5c}>";
963 const ASSISTANT: &str = "<\u{ff5c}hy_Assistant:opensource\u{ff5c}>";
964 const EOS: &str = "<\u{ff5c}hy_eos:opensource\u{ff5c}>";
965 const REASONING: &str = "<\u{ff5c}reasoning_mode:opensource\u{ff5c}>";
966 const THINK_BEGIN: &str = "<think:opensource>";
967 const THINK_END: &str = "</think:opensource>";
968
969 debug_assert!(
970 matches!(effort, "no_think" | "low" | "high"),
971 "hy3 reasoning_effort must be no_think|low|high, got {effort:?}"
972 );
973 let mut out = String::from(BOS);
974 for (role, content) in messages.iter().filter(|(r, _)| *r == "system") {
975 let _ = role;
976 out.push_str(content);
977 }
978 out.push_str(REASONING);
979 out.push_str("reasoning_effort:");
980 out.push_str(effort);
981
982 let mut last_is_assistant = false;
983 let n = messages.len();
984 for (i, (role, content)) in messages.iter().enumerate() {
985 last_is_assistant = false;
986 match *role {
987 "user" => {
988 out.push_str(USER);
989 out.push_str(content);
990 }
991 "assistant" => {
992 out.push_str(ASSISTANT);
993 out.push_str(THINK_BEGIN);
994 out.push_str(THINK_END);
995 out.push_str(content);
996 if i + 1 < n {
997 out.push_str(EOS);
998 } // template: `not loop.last` gets eos
999 last_is_assistant = true;
1000 }
1001 _ => {} // system handled in the header; tool turns are out of scope here
1002 }
1003 }
1004 if add_generation_prompt && !last_is_assistant {
1005 out.push_str(ASSISTANT);
1006 out.push_str(THINK_BEGIN);
1007 if effort == "no_think" {
1008 out.push_str(THINK_END); // low/high leave the think channel OPEN (the golden)
1009 }
1010 }
1011 out
1012}
1013
1014/// gemma4 turn dialect (text-only path of the GGUF template, verified against the dumped
1015/// jinja — sha 36e3a42e…, goldens `research/step-sku-20260807/raw/thinking-goldens.txt`):
1016/// roles map assistant->model; each turn = `<|turn>{role}\n{content|trim}<turn|>\n`.
1017///
1018/// THINKING is `enable_thinking`, and its default is OFF (`enable_thinking | default(false)`)
1019/// — the inverse of the qwen class:
1020/// - thinking OFF (default): generation prompt = `<|turn>model\n<|channel>thought\n<channel|>`
1021/// (the CLOSED thought channel — the model may not think);
1022/// - thinking ON: a `<|think|>\n` token is injected at the very top of the FIRST system
1023/// turn (a system turn is CREATED if the request has none), and the generation prompt is
1024/// the bare `<|turn>model\n` — the thought channel is left to the model.
1025fn apply_gemma4_template(
1026 messages: &[(&str, &str)],
1027 add_generation_prompt: bool,
1028 thinking: bool,
1029) -> String {
1030 let mut out = String::new();
1031 let mut msgs = messages;
1032 // System header block: fires when thinking is on OR a leading system turn exists.
1033 let leading_system = msgs.first().filter(|(r, _)| *r == "system");
1034 if thinking || leading_system.is_some() {
1035 out.push_str("<|turn>system\n");
1036 if thinking {
1037 out.push_str("<|think|>\n");
1038 }
1039 if let Some((_, content)) = leading_system {
1040 out.push_str(content.trim());
1041 msgs = &msgs[1..];
1042 }
1043 out.push_str("<turn|>\n");
1044 }
1045 for (role, content) in msgs {
1046 let role = if *role == "assistant" { "model" } else { role };
1047 out.push_str("<|turn>");
1048 out.push_str(role);
1049 out.push('\n');
1050 out.push_str(content.trim());
1051 out.push_str("<turn|>\n");
1052 }
1053 if add_generation_prompt {
1054 out.push_str("<|turn>model\n");
1055 if !thinking {
1056 out.push_str("<|channel>thought\n<channel|>");
1057 }
1058 }
1059 out
1060}
1061
1062/// A template carries a tools branch iff it has the qwen/step `<tools>` block, or the gemma4
1063/// tooluse dialect (both the `<|turn>` turn framing and the `<|tool>` declaration marker).
1064/// hy3 (`hy_User`) never has one. Shared by the renderer dispatch and the worker caps probe.
1065pub fn template_has_tools_branch(t: &str) -> bool {
1066 if t.contains("hy_User") {
1067 return false;
1068 }
1069 template_is_dsv4(t) || t.contains("<tools>") || (t.contains("<|turn>") && t.contains("<|tool>"))
1070}
1071
1072/// deepseek-v4 (`encoding_dsv4`) template detector: the `<|Assistant|>` turn prefix AND the
1073/// `|DSML|` tool-call markup token. Both are unique to the DeepSeek-V4 chat dialect (`|`
1074/// is U+FF5C, `<think>` alone would be ambiguous with the qwen class). Shared by the renderer
1075/// dispatch, the tools-branch probe, and the worker caps.
1076pub fn template_is_dsv4(t: &str) -> bool {
1077 t.contains("<\u{ff5c}Assistant\u{ff5c}>") && t.contains("\u{ff5c}DSML\u{ff5c}")
1078}
1079
1080// ---- gemma4 tooluse dialect ---------------------------------------------------------------
1081// A faithful port of research/gemma4-tools-20260817/official-tooluse-template.jinja (extracted
1082// byte-identical from the official Q8_0-MTP GGUF — the served trunk). The jinja is the LAW;
1083// byte parity is pinned by research/gemma4-tools-20260817/fixtures (the `gemma4_tools_fixtures`
1084// test in memra-server renders the official jinja under jinja2 and asserts equality). Deviation
1085// from the jinja: an unresolved tool-response name falls back to "unknown" instead of crashing
1086// on `str + None` (the jinja's `.get('name') | default('unknown')` renders None, then the
1087// concat raises) — unreachable from OpenAI histories, where the id always resolves.
1088
1089/// jinja `| dictsort`: case-insensitive by key, STABLE (ties keep insertion order).
1090fn dictsort(pairs: &[(String, Val)]) -> Vec<&(String, Val)> {
1091 let mut v: Vec<&(String, Val)> = pairs.iter().collect();
1092 v.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
1093 v
1094}
1095
1096/// jinja `format_argument(argument, escape_keys)`: strings wrapped in `<|"|>`, bools `true`/
1097/// `false`, mappings `{k:v,...}` (keys bare unless `escape_keys`, dictsorted, recursive),
1098/// sequences `[v,...]`, null -> `None` (jinja `{{ none }}`), numbers bare.
1099fn format_argument(v: &Val, escape_keys: bool) -> String {
1100 match v {
1101 Val::Str(s) => format!("<|\"|>{s}<|\"|>"),
1102 Val::Bool(b) => if *b { "true" } else { "false" }.to_string(),
1103 Val::Obj(pairs) => {
1104 let mut out = String::from("{");
1105 for (i, (k, val)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
1106 if i > 0 {
1107 out.push(',');
1108 }
1109 if escape_keys {
1110 out.push_str(&format!("<|\"|>{k}<|\"|>"));
1111 } else {
1112 out.push_str(k);
1113 }
1114 out.push(':');
1115 out.push_str(&format_argument(val, escape_keys));
1116 }
1117 out.push('}');
1118 out
1119 }
1120 Val::Arr(items) => {
1121 let mut out = String::from("[");
1122 for (i, item) in items.iter().enumerate() {
1123 if i > 0 {
1124 out.push(',');
1125 }
1126 out.push_str(&format_argument(item, escape_keys));
1127 }
1128 out.push(']');
1129 out
1130 }
1131 Val::Null => "None".to_string(),
1132 Val::Num(s) => s.clone(),
1133 }
1134}
1135
1136/// jinja `strip_thinking(text)`: drop every `<|channel>...<channel|>` span, then `| trim`.
1137/// Split on `<channel|>`; for each part, keep everything before a `<|channel>` (dropping the
1138/// channel body), else keep the whole part.
1139fn strip_thinking(text: &str) -> String {
1140 let mut result = String::new();
1141 for part in text.split("<channel|>") {
1142 match part.find("<|channel>") {
1143 Some(o) => result.push_str(&part[..o]),
1144 None => result.push_str(part),
1145 }
1146 }
1147 result.trim().to_string()
1148}
1149
1150fn val_get<'a>(obj: &'a [(String, Val)], key: &str) -> Option<&'a Val> {
1151 obj.iter().find(|(k, _)| k == key).map(|(_, v)| v)
1152}
1153fn as_obj(v: &Val) -> Option<&[(String, Val)]> {
1154 match v {
1155 Val::Obj(p) => Some(p),
1156 _ => None,
1157 }
1158}
1159fn as_str(v: &Val) -> Option<&str> {
1160 match v {
1161 Val::Str(s) => Some(s),
1162 _ => None,
1163 }
1164}
1165/// jinja truthiness for `if value[...]`: None/false/""/[]/{} are falsy.
1166fn truthy(v: &Val) -> bool {
1167 match v {
1168 Val::Null => false,
1169 Val::Bool(b) => *b,
1170 Val::Str(s) => !s.is_empty(),
1171 Val::Num(s) => s != "0" && s != "0.0",
1172 Val::Arr(a) => !a.is_empty(),
1173 Val::Obj(o) => !o.is_empty(),
1174 }
1175}
1176
1177/// jinja comma helper: emit ',' iff a prior element was written in THIS property object, then
1178/// mark that at least one has been written.
1179fn comma(out: &mut String, add: &mut bool) {
1180 if *add {
1181 out.push(',');
1182 } else {
1183 *add = true;
1184 }
1185}
1186
1187/// jinja `format_parameters(properties, _required_unused, filter_keys)`. The second jinja arg
1188/// (`required`) is never referenced in the macro body, so it is dropped here.
1189fn format_parameters(out: &mut String, props: &[(String, Val)], filter_keys: bool) {
1190 const STANDARD: [&str; 5] = ["description", "type", "properties", "required", "nullable"];
1191 let mut found_first = false;
1192 for (key, value) in dictsort(props).iter().map(|p| (&p.0, &p.1)) {
1193 if filter_keys && STANDARD.contains(&key.as_str()) {
1194 continue;
1195 }
1196 if found_first {
1197 out.push(',');
1198 }
1199 found_first = true;
1200 out.push_str(key);
1201 out.push_str(":{");
1202 let vobj = as_obj(value);
1203 let mut add = false;
1204 // description
1205 if let Some(d) = vobj
1206 .and_then(|o| val_get(o, "description"))
1207 .filter(|d| truthy(d))
1208 {
1209 out.push_str("description:<|\"|>");
1210 out.push_str(as_str(d).unwrap_or(""));
1211 out.push_str("<|\"|>");
1212 add = true;
1213 }
1214 let ty_up = vobj
1215 .and_then(|o| val_get(o, "type"))
1216 .and_then(as_str)
1217 .map(|s| s.to_uppercase());
1218 match ty_up.as_deref() {
1219 Some("STRING") => {
1220 if let Some(en) = vobj.and_then(|o| val_get(o, "enum")).filter(|e| truthy(e)) {
1221 comma(out, &mut add);
1222 out.push_str("enum:");
1223 out.push_str(&format_argument(en, true));
1224 }
1225 }
1226 Some("ARRAY") => {
1227 if let Some(items) = vobj
1228 .and_then(|o| val_get(o, "items"))
1229 .filter(|it| matches!(it, Val::Obj(o) if !o.is_empty()))
1230 {
1231 comma(out, &mut add);
1232 out.push_str("items:{");
1233 format_items(out, as_obj(items).unwrap());
1234 out.push('}');
1235 }
1236 }
1237 _ => {}
1238 }
1239 // nullable
1240 if vobj
1241 .and_then(|o| val_get(o, "nullable"))
1242 .is_some_and(truthy)
1243 {
1244 comma(out, &mut add);
1245 out.push_str("nullable:true");
1246 }
1247 // OBJECT: nested properties + required
1248 if ty_up.as_deref() == Some("OBJECT") {
1249 if let Some(sub) = vobj.and_then(|o| val_get(o, "properties")).and_then(as_obj) {
1250 comma(out, &mut add);
1251 out.push_str("properties:{");
1252 format_parameters(out, sub, false);
1253 out.push('}');
1254 } else if let Some(o) = vobj {
1255 // no explicit `properties`: treat the value's own keys as sub-properties,
1256 // filtering the standard schema keys (jinja `filter_keys=true` branch).
1257 comma(out, &mut add);
1258 out.push_str("properties:{");
1259 format_parameters(out, o, true);
1260 out.push('}');
1261 }
1262 if let Some(req) = vobj
1263 .and_then(|o| val_get(o, "required"))
1264 .filter(|r| truthy(r))
1265 {
1266 comma(out, &mut add);
1267 out.push_str("required:[");
1268 push_str_list(out, req);
1269 out.push(']');
1270 }
1271 }
1272 // closing `type:<|"|>UPPER<|"|>}` (always) — carries a leading comma iff anything above.
1273 comma(out, &mut add);
1274 out.push_str("type:<|\"|>");
1275 out.push_str(ty_up.as_deref().unwrap_or(""));
1276 out.push_str("<|\"|>}");
1277 }
1278}
1279
1280/// The ARRAY `items` mapping loop: dictsorts item keys, skips None values, and renders
1281/// properties/required/type specially, else generic `key:format_argument(value)`.
1282fn format_items(out: &mut String, items: &[(String, Val)]) {
1283 let mut found_first = false;
1284 for (k, v) in dictsort(items).iter().map(|p| (&p.0, &p.1)) {
1285 if matches!(v, Val::Null) {
1286 continue;
1287 }
1288 if found_first {
1289 out.push(',');
1290 }
1291 found_first = true;
1292 match k.as_str() {
1293 "properties" => {
1294 out.push_str("properties:{");
1295 if let Some(o) = as_obj(v) {
1296 format_parameters(out, o, false);
1297 }
1298 out.push('}');
1299 }
1300 "required" => {
1301 out.push_str("required:[");
1302 push_str_list(out, v);
1303 out.push(']');
1304 }
1305 "type" => {
1306 out.push_str("type:");
1307 match v {
1308 Val::Str(s) => {
1309 out.push_str(&format_argument(&Val::Str(s.to_uppercase()), true))
1310 }
1311 Val::Arr(a) => {
1312 let upper: Vec<Val> = a
1313 .iter()
1314 .map(|x| Val::Str(as_str(x).unwrap_or("").to_uppercase()))
1315 .collect();
1316 out.push_str(&format_argument(&Val::Arr(upper), true));
1317 }
1318 other => out.push_str(&format_argument(other, true)),
1319 }
1320 }
1321 _ => {
1322 out.push_str(k);
1323 out.push(':');
1324 out.push_str(&format_argument(v, true));
1325 }
1326 }
1327 }
1328}
1329
1330/// `[<|"|>a<|"|>,<|"|>b<|"|>]` body (without the brackets) from a Val::Arr of strings.
1331fn push_str_list(out: &mut String, v: &Val) {
1332 if let Val::Arr(items) = v {
1333 for (i, item) in items.iter().enumerate() {
1334 if i > 0 {
1335 out.push(',');
1336 }
1337 out.push_str("<|\"|>");
1338 out.push_str(as_str(item).unwrap_or(""));
1339 out.push_str("<|\"|>");
1340 }
1341 }
1342}
1343
1344/// jinja `format_function_declaration(tool_data)` — `func` is the tool's `function` object.
1345fn format_function_declaration(func: &[(String, Val)]) -> String {
1346 let mut out = String::new();
1347 out.push_str("declaration:");
1348 out.push_str(val_get(func, "name").and_then(as_str).unwrap_or(""));
1349 out.push_str("{description:<|\"|>");
1350 out.push_str(val_get(func, "description").and_then(as_str).unwrap_or(""));
1351 out.push_str("<|\"|>");
1352 if let Some(params) = val_get(func, "parameters").filter(|p| truthy(p)) {
1353 let pobj = as_obj(params);
1354 out.push_str(",parameters:{");
1355 if let Some(props) = pobj
1356 .and_then(|o| val_get(o, "properties"))
1357 .filter(|p| truthy(p))
1358 .and_then(as_obj)
1359 {
1360 out.push_str("properties:{");
1361 format_parameters(&mut out, props, false);
1362 out.push_str("},");
1363 }
1364 if let Some(req) = pobj
1365 .and_then(|o| val_get(o, "required"))
1366 .filter(|r| truthy(r))
1367 {
1368 out.push_str("required:[");
1369 push_str_list(&mut out, req);
1370 out.push_str("],");
1371 }
1372 if let Some(ty) = pobj.and_then(|o| val_get(o, "type")).filter(|t| truthy(t)) {
1373 out.push_str("type:<|\"|>");
1374 out.push_str(&as_str(ty).unwrap_or("").to_uppercase());
1375 out.push_str("<|\"|>}");
1376 }
1377 }
1378 if let Some(resp) = val_get(func, "response").and_then(as_obj) {
1379 out.push_str(",response:{");
1380 if let Some(d) = val_get(resp, "description").filter(|d| truthy(d)) {
1381 out.push_str("description:<|\"|>");
1382 out.push_str(as_str(d).unwrap_or(""));
1383 out.push_str("<|\"|>,");
1384 }
1385 if val_get(resp, "type")
1386 .and_then(as_str)
1387 .map(|s| s.to_uppercase())
1388 == Some("OBJECT".into())
1389 {
1390 out.push_str("type:<|\"|>OBJECT<|\"|>}");
1391 }
1392 }
1393 out.push('}');
1394 out
1395}
1396
1397/// jinja `format_tool_response_block(tool_name, response)`.
1398fn format_tool_response_block(name: &str, response: &Val) -> String {
1399 let mut out = String::from("<|tool_response>");
1400 match response {
1401 Val::Obj(pairs) => {
1402 out.push_str("response:");
1403 out.push_str(name);
1404 out.push('{');
1405 for (i, (k, v)) in dictsort(pairs).iter().map(|p| (&p.0, &p.1)).enumerate() {
1406 if i > 0 {
1407 out.push(',');
1408 }
1409 out.push_str(k);
1410 out.push(':');
1411 out.push_str(&format_argument(v, false));
1412 }
1413 out.push('}');
1414 }
1415 other => {
1416 out.push_str("response:");
1417 out.push_str(name);
1418 out.push_str("{value:");
1419 out.push_str(&format_argument(other, false));
1420 out.push('}');
1421 }
1422 }
1423 out.push_str("<tool_response|>");
1424 out
1425}
1426
1427/// gemma4 tooluse renderer. `tools` are the tool `function` objects; `thinking` = jinja
1428/// `enable_thinking`; `closed_tail` = the QAT-trunk variant that emits a closed thought
1429/// channel on the thinking-off generation prompt (the official served trunk does not). BOS is
1430/// NOT emitted (encode(add_special) supplies it — the jinja's `{{ bos_token }}` is dropped).
1431fn apply_gemma4_tools_template(
1432 turns: &[Turn],
1433 add_generation_prompt: bool,
1434 tools: &[Val],
1435 thinking: bool,
1436 closed_tail: bool,
1437) -> String {
1438 let mut out = String::new();
1439 let mut prev: Option<&str> = None;
1440 let mut msgs = turns;
1441 let is_sys = |r: &str| r == "system" || r == "developer";
1442
1443 let leading_system = msgs.first().filter(|t| is_sys(&t.role));
1444 if thinking || !tools.is_empty() || leading_system.is_some() {
1445 out.push_str("<|turn>system\n");
1446 if thinking {
1447 out.push_str("<|think|>\n");
1448 prev = Some("think");
1449 }
1450 if let Some(sys) = leading_system {
1451 out.push_str(sys.content.trim());
1452 msgs = &msgs[1..];
1453 }
1454 for tool in tools {
1455 out.push_str("<|tool>");
1456 if let Some(func) = as_obj(tool) {
1457 out.push_str(format_function_declaration(func).trim());
1458 }
1459 out.push_str("<tool|>");
1460 }
1461 if !tools.is_empty() {
1462 prev = Some("tool");
1463 }
1464 out.push_str("<turn|>\n");
1465 }
1466
1467 let last_user_idx: isize = msgs
1468 .iter()
1469 .enumerate()
1470 .rev()
1471 .find(|(_, t)| t.role == "user")
1472 .map(|(i, _)| i as isize)
1473 .unwrap_or(-1);
1474
1475 for (i, m) in msgs.iter().enumerate() {
1476 if m.role == "tool" {
1477 continue; // consumed by a preceding assistant's forward-scan
1478 }
1479 prev = None;
1480 let role = if m.role == "assistant" {
1481 "model"
1482 } else {
1483 m.role.as_str()
1484 };
1485 let prev_nt_role = (0..i)
1486 .rev()
1487 .map(|j| &msgs[j])
1488 .find(|t| t.role != "tool")
1489 .map(|t| t.role.as_str());
1490 let continue_same_model_turn = role == "model" && prev_nt_role == Some("assistant");
1491 if !continue_same_model_turn {
1492 out.push_str("<|turn>");
1493 out.push_str(role);
1494 out.push('\n');
1495 }
1496
1497 // reasoning re-render (tool_calls-carrying assistant after the last user turn)
1498 if let Some(rt) = m.reasoning.as_deref() {
1499 if !rt.is_empty() && (i as isize) > last_user_idx && !m.tool_calls.is_empty() {
1500 out.push_str("<|channel>thought\n");
1501 out.push_str(rt);
1502 out.push_str("\n<channel|>");
1503 }
1504 }
1505
1506 // tool_calls
1507 if !m.tool_calls.is_empty() {
1508 for tc in &m.tool_calls {
1509 out.push_str("<|tool_call>call:");
1510 out.push_str(&tc.name);
1511 out.push('{');
1512 for (j, (k, v)) in dictsort(&tc.args).iter().map(|p| (&p.0, &p.1)).enumerate() {
1513 if j > 0 {
1514 out.push(',');
1515 }
1516 out.push_str(k);
1517 out.push(':');
1518 out.push_str(&format_argument(v, false));
1519 }
1520 out.push_str("}<tool_call|>");
1521 }
1522 prev = Some("tool_call");
1523 }
1524
1525 // tool responses: native (Google) on the assistant, else OpenAI role:"tool" forward-scan
1526 let mut tr_flag = false;
1527 if !m.tool_responses.is_empty() {
1528 for (name, resp) in &m.tool_responses {
1529 out.push_str(&format_tool_response_block(name, resp));
1530 tr_flag = true;
1531 prev = Some("tool_response");
1532 }
1533 } else if !m.tool_calls.is_empty() {
1534 for k in (i + 1)..msgs.len() {
1535 let follow = &msgs[k];
1536 if follow.role != "tool" {
1537 break;
1538 }
1539 let mut name = follow
1540 .tool_name
1541 .clone()
1542 .unwrap_or_else(|| "unknown".to_string());
1543 if let Some(fid) = follow.tool_call_id.as_deref() {
1544 for tc in &m.tool_calls {
1545 if tc.id.as_deref() == Some(fid) {
1546 name = tc.name.clone();
1547 }
1548 }
1549 }
1550 out.push_str(&format_tool_response_block(
1551 &name,
1552 &Val::Str(follow.content.clone()),
1553 ));
1554 tr_flag = true;
1555 prev = Some("tool_response");
1556 }
1557 }
1558
1559 // content (model content strips thought channels; other roles trim)
1560 let captured = if role == "model" {
1561 strip_thinking(&m.content)
1562 } else {
1563 m.content.trim().to_string()
1564 };
1565 out.push_str(&captured);
1566 let has_content = !captured.trim().is_empty();
1567
1568 if prev == Some("tool_call") && !tr_flag {
1569 out.push_str("<|tool_response>"); // dangling open: calls with no responses yet
1570 } else if !(tr_flag && !has_content) {
1571 out.push_str("<turn|>\n");
1572 }
1573 }
1574
1575 if add_generation_prompt && prev != Some("tool_response") && prev != Some("tool_call") {
1576 out.push_str("<|turn>model\n");
1577 if closed_tail && !thinking {
1578 out.push_str("<|channel>thought\n<channel|>");
1579 }
1580 }
1581 out
1582}
1583
1584// ---- deepseek-v4 (encoding_dsv4) dialect --------------------------------------------------
1585// A faithful port of encoding_dsv4.py in BOTH shipped revisions: the preview oracle
1586// (research/dsv4-template-20260818/ref/encoding/encoding_dsv4.py, sha256 bdbd57c1…) and the
1587// 0731 oracle (…/ref-0731/encoding/encoding_dsv4.py, sha256 abc0d261…), which differ ONLY in
1588// the reasoning-effort ladder (full behavioral diff: ENCODING-DIFF.md; selection law:
1589// `Dsv4Encoding`). The python IS the law; byte parity is pinned by
1590// research/dsv4-template-20260818/fixtures (preview matrix) + fixtures-0731 (0731 matrix)
1591// plus the artifact's authoritative encoding/tests/test_output_{1..4} (byte-identical across
1592// both revisions). See TEMPLATE-SEMANTICS.md for the census + banked ambiguities. Deviation
1593// from the python: none in the renderer (the parser deviates on malformed spans per house
1594// policy — see toolcall.rs).
1595
1596// U+FF5C is the fullwidth vertical line `|` in every DeepSeek special token; U+2581 the ▁.
1597const DS_BOS: &str = "<\u{ff5c}begin\u{2581}of\u{2581}sentence\u{ff5c}>";
1598const DS_EOS: &str = "<\u{ff5c}end\u{2581}of\u{2581}sentence\u{ff5c}>";
1599const DS_USER: &str = "<\u{ff5c}User\u{ff5c}>";
1600const DS_ASSISTANT: &str = "<\u{ff5c}Assistant\u{ff5c}>";
1601const DS_REMINDER: &str = "<\u{ff5c}latest_reminder\u{ff5c}>";
1602const DS_THINK_START: &str = "<think>";
1603const DS_THINK_END: &str = "</think>";
1604const DS_DSML: &str = "\u{ff5c}DSML\u{ff5c}";
1605// preview encoding_dsv4 REASONING_EFFORT_MAX (E:64-68) == 0731 REASONING_EFFORT_PROMPTS["high"]
1606// (0731 E:64-77 — same bytes, one ladder rung lower). Ends with "\n\n".
1607const DS_EFFORT_ABSOLUTE_MAX: &str = "Reasoning Effort: Absolute maximum with no shortcuts permitted.\nYou MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\nExplicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n";
1608// 0731 REASONING_EFFORT_PROMPTS["max"] (0731 E:70-75) — the new, stronger top rung. The dash
1609// is U+2014 EM DASH in the source; ends with "\n\n". Not present in the preview encoding.
1610const DS_EFFORT_BEYOND_MAX: &str = "Reasoning Effort: Beyond maximum \u{2014} exhaustive, relentless, and uncompromising.\nYou MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\nDo not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n";
1611
1612/// The reasoning-effort prompt prefix for one render (encoding_dsv4 preview E:260-263 /
1613/// 0731 E:270-277). `Ok("")` = no prefix. Errs ONLY on the ambiguous cell: an effort level
1614/// whose bytes differ between the two encodings (`"high"`/`"max"` in thinking mode) with no
1615/// encoding revision supplied — every other input renders identically under both revisions,
1616/// so it stays infallible there (the legacy no-effort dispatch relies on that).
1617///
1618/// Levels outside the encoding's accepted set (e.g. OpenAI "medium", which neither revision
1619/// defines) render as the default level, i.e. no prefix — the renderer never corrupts a
1620/// prompt over a knob the template does not consume (hy3 medium-clamp precedent).
1621fn dsv4_effort_prefix(
1622 thinking: bool,
1623 effort: Option<&str>,
1624 encoding: Option<Dsv4Encoding>,
1625) -> Result<&'static str, String> {
1626 if !thinking {
1627 // chat mode: no prefix under either encoding (preview E:262 / 0731 E:275 both gate
1628 // on thinking_mode == "thinking").
1629 return Ok("");
1630 }
1631 match effort {
1632 // None: preview renders nothing; 0731 defaults None -> "low" -> "" (E:271, E:66).
1633 // "low": 0731 default rung (no prefix); the preview oracle rejects the string, and
1634 // rendering no prefix is the only never-corrupt reading (banked, ENCODING-DIFF.md).
1635 None | Some("low") => Ok(""),
1636 Some("high") => match encoding {
1637 Some(Dsv4Encoding::Preview) => Ok(""), // preview law: "high" == None (E:261-263)
1638 Some(Dsv4Encoding::V0731) => Ok(DS_EFFORT_ABSOLUTE_MAX),
1639 None => Err(
1640 "dsv4 reasoning_effort \"high\" renders differently on the preview vs 0731 \
1641 encoding and this artifact's encoding revision is unknown (config.json \
1642 dspark_* census unavailable) — refusing rather than guessing"
1643 .into(),
1644 ),
1645 },
1646 Some("max") => match encoding {
1647 Some(Dsv4Encoding::Preview) => Ok(DS_EFFORT_ABSOLUTE_MAX),
1648 Some(Dsv4Encoding::V0731) => Ok(DS_EFFORT_BEYOND_MAX),
1649 None => Err(
1650 "dsv4 reasoning_effort \"max\" renders differently on the preview vs 0731 \
1651 encoding and this artifact's encoding revision is unknown (config.json \
1652 dspark_* census unavailable) — refusing rather than guessing"
1653 .into(),
1654 ),
1655 },
1656 Some(_) => Ok(""),
1657 }
1658}
1659
1660/// encoding_dsv4 DS_TASK_SP_TOKENS (E:28-35). The task token for a quick-instruction head.
1661fn ds_task_token(task: &str) -> Option<&'static str> {
1662 match task {
1663 "action" => Some("<\u{ff5c}action\u{ff5c}>"),
1664 "query" => Some("<\u{ff5c}query\u{ff5c}>"),
1665 "authority" => Some("<\u{ff5c}authority\u{ff5c}>"),
1666 "domain" => Some("<\u{ff5c}domain\u{ff5c}>"),
1667 "title" => Some("<\u{ff5c}title\u{ff5c}>"),
1668 "read_url" => Some("<\u{ff5c}read_url\u{ff5c}>"),
1669 _ => None,
1670 }
1671}
1672
1673/// python `json.dumps(v, ensure_ascii=False)` over a `Val` (encoding_dsv4 `to_json`, E:101-106):
1674/// default separators `", "` / `": "`, insertion key order, non-ASCII raw, `Num` exact text.
1675/// serde-free (this crate ships no serde) — the escaper below matches json.dumps exactly.
1676fn dsv4_json(v: &Val, out: &mut String) {
1677 match v {
1678 Val::Null => out.push_str("null"),
1679 Val::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
1680 Val::Num(s) => out.push_str(s),
1681 Val::Str(s) => {
1682 out.push('"');
1683 dsv4_json_escape(s, out);
1684 out.push('"');
1685 }
1686 Val::Arr(a) => {
1687 out.push('[');
1688 for (i, x) in a.iter().enumerate() {
1689 if i > 0 {
1690 out.push_str(", ");
1691 }
1692 dsv4_json(x, out);
1693 }
1694 out.push(']');
1695 }
1696 Val::Obj(o) => {
1697 out.push('{');
1698 for (i, (k, val)) in o.iter().enumerate() {
1699 if i > 0 {
1700 out.push_str(", ");
1701 }
1702 out.push('"');
1703 dsv4_json_escape(k, out);
1704 out.push_str("\": ");
1705 dsv4_json(val, out);
1706 }
1707 out.push('}');
1708 }
1709 }
1710}
1711
1712/// JSON string escaping matching python `json.dumps(ensure_ascii=False)`: `"` `\` and the
1713/// C0 escapes; other control chars < 0x20 become `\u00xx`; everything else (incl. non-ASCII)
1714/// passes through raw. json.dumps does NOT escape `/` or DEL.
1715fn dsv4_json_escape(s: &str, out: &mut String) {
1716 for c in s.chars() {
1717 match c {
1718 '"' => out.push_str("\\\""),
1719 '\\' => out.push_str("\\\\"),
1720 '\n' => out.push_str("\\n"),
1721 '\r' => out.push_str("\\r"),
1722 '\t' => out.push_str("\\t"),
1723 '\u{8}' => out.push_str("\\b"),
1724 '\u{c}' => out.push_str("\\f"),
1725 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1726 c => out.push(c),
1727 }
1728 }
1729}
1730
1731/// encoding_dsv4 `render_tools` (E:189-206) + TOOLS_TEMPLATE (E:70-95): the tool-declaration
1732/// block appended to a system/developer turn. `funcs` are the tool `function` objects
1733/// (encoding_dsv4 `tools_from_openai_format`). Ends with a trailing `\n`.
1734fn dsv4_render_tools(funcs: &[Val]) -> String {
1735 let mut schemas = String::new();
1736 for (i, f) in funcs.iter().enumerate() {
1737 if i > 0 {
1738 schemas.push('\n');
1739 }
1740 dsv4_json(f, &mut schemas);
1741 }
1742 format!(
1743 "## Tools\n\nYou have access to a set of tools to help answer the user's question. \
1744You can invoke tools by writing a \"<{d}tool_calls>\" block like the following:\n\n\
1745<{d}tool_calls>\n<{d}invoke name=\"$TOOL_NAME\">\n\
1746<{d}parameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</{d}parameter>\n\
1747...\n</{d}invoke>\n<{d}invoke name=\"$TOOL_NAME2\">\n...\n</{d}invoke>\n</{d}tool_calls>\n\n\
1748String parameters should be specified as is and set `string=\"true\"`. For all other types \
1749(numbers, booleans, arrays, objects), pass the value in JSON format and set `string=\"false\"`.\
1750\n\nIf thinking_mode is enabled (triggered by {ts}), you MUST output your complete reasoning \
1751inside {ts}...{te} BEFORE any tool calls or final response.\n\nOtherwise, output directly \
1752after {te} with tool calls or final response.\n\n### Available Tool Schemas\n\n{schemas}\n\n\
1753You MUST strictly follow the above defined tool name and parameter schemas to invoke tool \
1754calls.\n",
1755 d = DS_DSML,
1756 ts = DS_THINK_START,
1757 te = DS_THINK_END,
1758 schemas = schemas,
1759 )
1760}
1761
1762/// One assistant tool_calls block (encoding_dsv4 E:52-58, E:139-166, E:323-336): the `\n\n`
1763/// prefix + `<|DSML|tool_calls>` wrapper + one `<|DSML|invoke>` per call, each argument a
1764/// `<|DSML|parameter>` line (string values raw with `string="true"`, everything else
1765/// json.dumps'd with `string="false"`). Argument order = insertion order (NO dictsort).
1766fn dsv4_render_tool_calls(calls: &[ToolCall]) -> String {
1767 let mut invokes = String::new();
1768 for (i, call) in calls.iter().enumerate() {
1769 if i > 0 {
1770 invokes.push('\n');
1771 }
1772 invokes.push_str(&format!(
1773 "<{d}invoke name=\"{n}\">\n",
1774 d = DS_DSML,
1775 n = call.name
1776 ));
1777 for (j, (k, v)) in call.args.iter().enumerate() {
1778 if j > 0 {
1779 invokes.push('\n');
1780 }
1781 let is_str = matches!(v, Val::Str(_));
1782 invokes.push_str(&format!(
1783 "<{d}parameter name=\"{k}\" string=\"{b}\">",
1784 d = DS_DSML,
1785 k = k,
1786 b = if is_str { "true" } else { "false" },
1787 ));
1788 match v {
1789 Val::Str(s) => invokes.push_str(s),
1790 other => dsv4_json(other, &mut invokes),
1791 }
1792 invokes.push_str(&format!("</{d}parameter>", d = DS_DSML));
1793 }
1794 invokes.push_str(&format!("\n</{d}invoke>", d = DS_DSML));
1795 }
1796 format!(
1797 "\n\n<{d}tool_calls>\n{invokes}\n</{d}tool_calls>",
1798 d = DS_DSML,
1799 invokes = invokes
1800 )
1801}
1802
1803/// One merged content block on a user turn (encoding_dsv4 content_blocks, E:289-309).
1804enum DsBlock {
1805 Text(String),
1806 ToolResult {
1807 content: String,
1808 tool_use_id: String,
1809 },
1810}
1811
1812/// One preprocessed message (post merge_tool_messages / sort). `blocks` is Some for user
1813/// turns (a merged run of user text + tool results); other roles carry `content`.
1814struct DsMsg {
1815 role: String,
1816 content: String,
1817 blocks: Option<Vec<DsBlock>>,
1818 reasoning: String,
1819 tool_calls: Vec<ToolCall>,
1820 tools: Vec<Val>,
1821 task: Option<String>,
1822}
1823
1824/// encoding_dsv4 `merge_tool_messages` (E:401-457): fold role:"tool" turns and consecutive
1825/// user turns into single `<|User|>` turns carrying `content_blocks`. `req_tools` are the
1826/// request-level tool `function` objects attached to the LEADING system turn (matching the
1827/// serve surface; a synthetic empty system turn is created when tools exist with no system
1828/// turn — the oracle's render of {"role":"system","content":"","tools":[...]}). A turn's own
1829/// `tools` (fixture harness, e.g. tools on a developer message) take precedence.
1830fn dsv4_merge(turns: &[Turn], req_tools: &[Val]) -> Vec<DsMsg> {
1831 let mut merged: Vec<DsMsg> = Vec::new();
1832 let any_turn_tools = turns.iter().any(|t| !t.tools.is_empty());
1833 // Serve surface: request-level tools ride the leading system turn (or a synthetic one).
1834 let mut leading_tools_pending = !req_tools.is_empty() && !any_turn_tools;
1835 if leading_tools_pending && !turns.first().map(|t| t.role == "system").unwrap_or(false) {
1836 merged.push(DsMsg {
1837 role: "system".into(),
1838 content: String::new(),
1839 blocks: None,
1840 reasoning: String::new(),
1841 tool_calls: Vec::new(),
1842 tools: req_tools.to_vec(),
1843 task: None,
1844 });
1845 leading_tools_pending = false;
1846 }
1847 for turn in turns {
1848 match turn.role.as_str() {
1849 "tool" => {
1850 let block = DsBlock::ToolResult {
1851 content: turn.content.clone(),
1852 tool_use_id: turn.tool_call_id.clone().unwrap_or_default(),
1853 };
1854 match merged.last_mut() {
1855 Some(m) if m.role == "user" && m.blocks.is_some() => {
1856 m.blocks.as_mut().unwrap().push(block);
1857 }
1858 _ => merged.push(DsMsg {
1859 role: "user".into(),
1860 content: String::new(),
1861 blocks: Some(vec![block]),
1862 reasoning: String::new(),
1863 tool_calls: Vec::new(),
1864 tools: Vec::new(),
1865 task: None,
1866 }),
1867 }
1868 }
1869 "user" => {
1870 let text = DsBlock::Text(turn.content.clone());
1871 match merged.last_mut() {
1872 Some(m) if m.role == "user" && m.blocks.is_some() && m.task.is_none() => {
1873 m.blocks.as_mut().unwrap().push(text);
1874 }
1875 _ => merged.push(DsMsg {
1876 role: "user".into(),
1877 content: turn.content.clone(),
1878 blocks: Some(vec![text]),
1879 reasoning: String::new(),
1880 tool_calls: Vec::new(),
1881 tools: turn.tools.clone(),
1882 task: turn.task.clone(),
1883 }),
1884 }
1885 }
1886 role => {
1887 let mut tools = turn.tools.clone();
1888 if role == "system" && leading_tools_pending && merged.is_empty() {
1889 tools = req_tools.to_vec();
1890 leading_tools_pending = false;
1891 }
1892 merged.push(DsMsg {
1893 role: role.to_string(),
1894 content: turn.content.clone(),
1895 blocks: None,
1896 reasoning: turn.reasoning.clone().unwrap_or_default(),
1897 tool_calls: turn.tool_calls.clone(),
1898 tools,
1899 task: turn.task.clone(),
1900 });
1901 }
1902 }
1903 }
1904 merged
1905}
1906
1907/// encoding_dsv4 `sort_tool_results_by_call_order` (E:460-499): within a user turn holding
1908/// more than one tool_result block, order those blocks by the preceding assistant's
1909/// tool_calls id order (stable; an unknown id sorts as 0). Non-tool block positions are kept.
1910#[allow(clippy::needless_range_loop)] // indexed: reads earlier turns' order, mutates msgs[i]
1911fn dsv4_sort_tool_results(msgs: &mut [DsMsg]) {
1912 let mut order: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1913 // walk without holding an immutable borrow across the mutable block edit.
1914 for i in 0..msgs.len() {
1915 if msgs[i].role == "assistant" && !msgs[i].tool_calls.is_empty() {
1916 order.clear();
1917 for (idx, tc) in msgs[i].tool_calls.iter().enumerate() {
1918 if let Some(id) = tc.id.as_deref() {
1919 if !id.is_empty() {
1920 order.insert(id.to_string(), idx);
1921 }
1922 }
1923 }
1924 } else if msgs[i].role == "user" {
1925 let n_tool = msgs[i]
1926 .blocks
1927 .as_ref()
1928 .map(|b| {
1929 b.iter()
1930 .filter(|x| matches!(x, DsBlock::ToolResult { .. }))
1931 .count()
1932 })
1933 .unwrap_or(0);
1934 if n_tool > 1 && !order.is_empty() {
1935 let blocks = msgs[i].blocks.take().unwrap();
1936 // stable sort the tool_result blocks by call order; keep others in place.
1937 let mut tool_blocks: Vec<DsBlock> = Vec::new();
1938 let mut positions: Vec<bool> = Vec::new(); // true = tool_result slot
1939 let mut others: Vec<DsBlock> = Vec::new();
1940 for b in blocks {
1941 match b {
1942 DsBlock::ToolResult { .. } => {
1943 positions.push(true);
1944 tool_blocks.push(b);
1945 }
1946 other => {
1947 positions.push(false);
1948 others.push(other);
1949 }
1950 }
1951 }
1952 tool_blocks.sort_by_key(|b| match b {
1953 DsBlock::ToolResult { tool_use_id, .. } => {
1954 *order.get(tool_use_id).unwrap_or(&0)
1955 }
1956 _ => 0,
1957 });
1958 let mut ti = tool_blocks.into_iter();
1959 let mut oi = others.into_iter();
1960 let rebuilt: Vec<DsBlock> = positions
1961 .into_iter()
1962 .map(|is_tool| {
1963 if is_tool {
1964 ti.next().unwrap()
1965 } else {
1966 oi.next().unwrap()
1967 }
1968 })
1969 .collect();
1970 msgs[i].blocks = Some(rebuilt);
1971 }
1972 }
1973 }
1974}
1975
1976/// index of the last user/developer message (encoding_dsv4 `find_last_user_index`, E:209-216).
1977fn dsv4_last_user_idx(msgs: &[DsMsg]) -> isize {
1978 for i in (0..msgs.len()).rev() {
1979 if msgs[i].role == "user" || msgs[i].role == "developer" {
1980 return i as isize;
1981 }
1982 }
1983 -1
1984}
1985
1986/// encoding_dsv4 `_drop_thinking_messages` (E:575-599): keep user/system/latest_reminder and
1987/// everything at/after the last user; strip reasoning from earlier assistants; drop earlier
1988/// developer (and other) turns entirely. Runs only in thinking mode with no tools declared.
1989fn dsv4_drop_thinking(msgs: Vec<DsMsg>) -> Vec<DsMsg> {
1990 let last = dsv4_last_user_idx(&msgs);
1991 let mut out = Vec::with_capacity(msgs.len());
1992 for (i, mut m) in msgs.into_iter().enumerate() {
1993 let keep_role = matches!(
1994 m.role.as_str(),
1995 "user" | "system" | "latest_reminder" | "direct_search_results"
1996 );
1997 if keep_role || (i as isize) >= last {
1998 out.push(m);
1999 } else if m.role == "assistant" {
2000 m.reasoning.clear();
2001 out.push(m);
2002 }
2003 // developer + others before the last user are dropped.
2004 }
2005 out
2006}
2007
2008/// Full port of encoding_dsv4 `encode_messages` (E:506-572) + `render_message` (E:223-394),
2009/// covering BOTH shipped encoding revisions (they differ only in the effort ladder — see
2010/// `Dsv4Encoding`).
2011///
2012/// ThinkMode maps onto encoding_dsv4's (thinking_mode, reasoning_effort):
2013///
2014/// - `Default` → thinking (the model has no template-own default; thinking_mode is a
2015/// REQUIRED arg and the README example + the model's agentic positioning make thinking
2016/// the honest default — see TEMPLATE-SEMANTICS.md finding #1);
2017/// - `Think` → thinking;
2018/// - `NoThink` → chat (the DeepSeek "Non-think" mode: `<|Assistant|></think>`).
2019///
2020/// The `reasoning_effort` string resolves through `dsv4_effort_prefix` per the artifact's
2021/// `encoding` revision (preview: "max" prefix only, "high" a documented no-op; 0731:
2022/// low/high/max ladder). `Err` ONLY when the requested (thinking, effort) cell renders
2023/// differently across revisions and `encoding` is `None` — the refuse-on-ambiguity law.
2024/// On the serve path the encoding rides the `Tokenizer` (config.json dspark_* census at
2025/// `from_hf_dir`); the HTTP layer forwards the OpenAI level for dsv4 models
2026/// (`ModelCaps::dsv4`), so "high" now reaches the 0731 ladder for real.
2027///
2028/// `req_tools` are the request-level tool `function` objects (attached to the leading system
2029/// turn); `add_generation_prompt` gates ONLY the final-message generation-prompt transition
2030/// (mid-conversation continuation transitions are always emitted, matching the python's
2031/// unconditional transition law).
2032fn apply_dsv4_template(
2033 turns: &[Turn],
2034 add_generation_prompt: bool,
2035 req_tools: &[Val],
2036 think: ThinkMode,
2037 reasoning_effort: Option<&str>,
2038 encoding: Option<Dsv4Encoding>,
2039) -> Result<String, String> {
2040 let thinking = think != ThinkMode::NoThink; // Default + Think -> thinking; NoThink -> chat
2041 let effort_prefix = dsv4_effort_prefix(thinking, reasoning_effort, encoding)?;
2042
2043 let mut msgs = dsv4_merge(turns, req_tools);
2044 dsv4_sort_tool_results(&mut msgs);
2045 // effective drop_thinking: default True, auto-disabled when any message declares tools.
2046 let any_tools = msgs.iter().any(|m| !m.tools.is_empty());
2047 let effective_drop = !any_tools;
2048 if thinking && effective_drop {
2049 msgs = dsv4_drop_thinking(msgs);
2050 }
2051 let last_user = dsv4_last_user_idx(&msgs);
2052 let n = msgs.len();
2053
2054 let mut out = String::from(DS_BOS);
2055 for idx in 0..n {
2056 let m = &msgs[idx];
2057 if idx == 0 {
2058 // effort prefix before the first rendered message (preview E:262-263 / 0731
2059 // E:275-277); "" when no prefix applies, so this is a no-op push then.
2060 out.push_str(effort_prefix);
2061 }
2062 match m.role.as_str() {
2063 "system" => {
2064 out.push_str(&m.content);
2065 if !m.tools.is_empty() {
2066 out.push_str("\n\n");
2067 out.push_str(&dsv4_render_tools(&m.tools));
2068 }
2069 }
2070 "developer" => {
2071 out.push_str(DS_USER);
2072 out.push_str(&m.content);
2073 if !m.tools.is_empty() {
2074 out.push_str("\n\n");
2075 out.push_str(&dsv4_render_tools(&m.tools));
2076 }
2077 }
2078 "user" => {
2079 out.push_str(DS_USER);
2080 if let Some(blocks) = &m.blocks {
2081 for (i, b) in blocks.iter().enumerate() {
2082 if i > 0 {
2083 out.push_str("\n\n");
2084 }
2085 match b {
2086 DsBlock::Text(t) => out.push_str(t),
2087 DsBlock::ToolResult { content, .. } => {
2088 out.push_str("<tool_result>");
2089 out.push_str(content);
2090 out.push_str("</tool_result>");
2091 }
2092 }
2093 }
2094 } else {
2095 out.push_str(&m.content);
2096 }
2097 }
2098 "latest_reminder" => {
2099 out.push_str(DS_REMINDER);
2100 out.push_str(&m.content);
2101 }
2102 "assistant" => {
2103 let prev_has_task = idx > 0 && msgs[idx - 1].task.is_some();
2104 let mut thinking_part = String::new();
2105 if thinking && !prev_has_task && (!effective_drop || (idx as isize) > last_user) {
2106 thinking_part.push_str(&m.reasoning);
2107 thinking_part.push_str(DS_THINK_END);
2108 }
2109 out.push_str(&thinking_part);
2110 out.push_str(&m.content);
2111 if !m.tool_calls.is_empty() {
2112 out.push_str(&dsv4_render_tool_calls(&m.tool_calls));
2113 }
2114 out.push_str(DS_EOS);
2115 }
2116 _ => {} // direct_search_results and unknown roles never render (E:362-363).
2117 }
2118
2119 // --- transition tokens (E:365-394) ---
2120 // Early-out: a non-final message whose next turn is NOT assistant/latest_reminder gets
2121 // no transition (the python's E:366 guard).
2122 if idx + 1 < n {
2123 let next = msgs[idx + 1].role.as_str();
2124 if next != "assistant" && next != "latest_reminder" {
2125 continue;
2126 }
2127 }
2128 let is_last = idx + 1 >= n;
2129 if let Some(task) = m.task.as_deref() {
2130 // generation-prompt-shaped: a task on the final message is gated on the gen prompt.
2131 if is_last && !add_generation_prompt {
2132 continue;
2133 }
2134 if let Some(tok) = ds_task_token(task) {
2135 if task != "action" {
2136 out.push_str(tok);
2137 } else {
2138 out.push_str(DS_ASSISTANT);
2139 out.push_str(if thinking {
2140 DS_THINK_START
2141 } else {
2142 DS_THINK_END
2143 });
2144 out.push_str(tok);
2145 }
2146 }
2147 } else if m.role == "user" || m.role == "developer" {
2148 if is_last && !add_generation_prompt {
2149 continue;
2150 }
2151 out.push_str(DS_ASSISTANT);
2152 // E:387-392: thinking opens `<think>` when drop_thinking is OFF (tools present)
2153 // OR (drop on) at/after the last user turn; else it closes `</think>`. chat mode
2154 // (thinking=false) always closes.
2155 if thinking && (!effective_drop || (idx as isize) >= last_user) {
2156 out.push_str(DS_THINK_START);
2157 } else {
2158 out.push_str(DS_THINK_END);
2159 }
2160 }
2161 }
2162 Ok(out)
2163}
2164
2165#[cfg(test)]
2166mod tests {
2167 use super::*;
2168
2169 /// ds4f rung-3 regression (the first real serve 400): the REAL dsv4 artifacts
2170 /// ship NO chat_template string — dispatch and the tools branch must key on the
2171 /// detected encoding revision, or a fully-defined dialect 400s at the door.
2172 #[test]
2173 fn templateless_dsv4_artifact_dispatches_on_encoding() {
2174 let s =
2175 apply_chat_template_enc(None, &[("user", "Hello")], true, Some(Dsv4Encoding::V0731))
2176 .unwrap();
2177 assert!(
2178 s.contains("<\u{ff5c}User\u{ff5c}>") && s.contains("<\u{ff5c}Assistant\u{ff5c}>"),
2179 "encoding dispatch did not reach the dsv4 renderer: {s:?}"
2180 );
2181 assert!(!s.contains("<|im_start|>"), "fell back to ChatML: {s:?}");
2182 let legacy = apply_chat_template_enc(None, &[("user", "Hello")], true, None).unwrap();
2183 assert_eq!(
2184 legacy,
2185 apply_chat_template_str(None, &[("user", "Hello")], true)
2186 );
2187
2188 let turns = vec![Turn {
2189 role: "user".into(),
2190 content: "What is the weather in Paris? Use the tool.".into(),
2191 ..Default::default()
2192 }];
2193 let tj = vec![
2194 r#"{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}"#.to_string(),
2195 ];
2196 // the dsv4 renderer consumes the typed tree (tools_struct), like the gemma dialect
2197 let tv = vec![Val::Obj(vec![
2198 ("name".into(), Val::Str("get_weather".into())),
2199 (
2200 "description".into(),
2201 Val::Str("Get weather for a city".into()),
2202 ),
2203 (
2204 "parameters".into(),
2205 Val::Obj(vec![
2206 ("type".into(), Val::Str("object".into())),
2207 (
2208 "properties".into(),
2209 Val::Obj(vec![(
2210 "city".into(),
2211 Val::Obj(vec![("type".into(), Val::Str("string".into()))]),
2212 )]),
2213 ),
2214 ]),
2215 ),
2216 ])];
2217 let out = apply_chat_template_tools_ex(
2218 None,
2219 &turns,
2220 true,
2221 &tj,
2222 &tv,
2223 ThinkMode::Default,
2224 None,
2225 Some(Dsv4Encoding::V0731),
2226 )
2227 .expect("templateless dsv4 artifact must render tools (DSML is its protocol)");
2228 assert!(
2229 out.contains("\u{ff5c}DSML\u{ff5c}") || out.contains("get_weather"),
2230 "tools block missing from the DSML render: {out:?}"
2231 );
2232 }
2233
2234 #[test]
2235 fn plain_chatml() {
2236 let s = apply_chat_template_str(None, &[("user", "Hello")], true);
2237 assert_eq!(
2238 s,
2239 "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n"
2240 );
2241 }
2242
2243 /// A template stand-in carrying every marker the real qwen3.5/3.6 dumps carry
2244 /// (tools branch + think tail + enable_thinking switch).
2245 const QWEN_TOOLS_TMPL: &str =
2246 "... <tools> ... add_generation_prompt ... enable_thinking ... '<think>\\n' ...";
2247
2248 /// Isolation contract: the tools renderer on a PLAIN request (no tools, no tool turns,
2249 /// Default think) is byte-identical to the legacy renderer, across the message shapes
2250 /// the serve path sees.
2251 #[test]
2252 fn tools_renderer_matches_legacy_when_plain() {
2253 let batteries: &[&[(&str, &str)]] = &[
2254 &[("user", "Hello")],
2255 &[("system", "You are helpful."), ("user", "Hi")],
2256 &[
2257 ("system", "rules"),
2258 ("user", "task"),
2259 ("assistant", "work"),
2260 ("user", "more"),
2261 ],
2262 &[("user", " padded "), ("assistant", "reply\nwith lines")],
2263 ];
2264 for tmpl in [None, Some(QWEN_TOOLS_TMPL)] {
2265 for msgs in batteries {
2266 let legacy = apply_chat_template_str(tmpl, msgs, true);
2267 let turns: Vec<Turn> = msgs
2268 .iter()
2269 .map(|(r, c)| Turn {
2270 role: r.to_string(),
2271 content: c.to_string(),
2272 tool_calls: Vec::new(),
2273 ..Default::default()
2274 })
2275 .collect();
2276 let ext =
2277 apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
2278 .unwrap();
2279 assert_eq!(legacy, ext, "template={tmpl:?} msgs={msgs:?}");
2280 }
2281 }
2282 }
2283
2284 #[test]
2285 fn tools_header_and_tool_response_render_per_template_law() {
2286 let tools =
2287 vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
2288 let turns = vec![
2289 Turn {
2290 role: "system".into(),
2291 content: "Be terse.".into(),
2292 tool_calls: Vec::new(),
2293 ..Default::default()
2294 },
2295 Turn {
2296 role: "user".into(),
2297 content: "Weather in Paris?".into(),
2298 tool_calls: Vec::new(),
2299 ..Default::default()
2300 },
2301 Turn {
2302 role: "assistant".into(),
2303 content: "".into(),
2304 tool_calls: vec![ToolCall {
2305 name: "get_weather".into(),
2306 params: vec![("city".into(), "Paris".into())],
2307 ..Default::default()
2308 }],
2309 ..Default::default()
2310 },
2311 Turn {
2312 role: "tool".into(),
2313 content: "{\"temp_c\": 21}".into(),
2314 tool_calls: Vec::new(),
2315 ..Default::default()
2316 },
2317 ];
2318 let s = apply_chat_template_tools(
2319 Some(QWEN_TOOLS_TMPL),
2320 &turns,
2321 true,
2322 &tools,
2323 ThinkMode::Default,
2324 None,
2325 )
2326 .unwrap();
2327 let expected = concat!(
2328 "<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n",
2329 "<tools>\n{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n</tools>",
2330 "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
2331 "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
2332 "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
2333 "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
2334 "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
2335 "<function=...></function> block must be nested within <tool_call></tool_call> XML tags\n",
2336 "- Required parameters MUST be specified\n- You may provide optional reasoning for your ",
2337 "function call in natural language BEFORE the function call, but NOT after\n- If there is ",
2338 "no function call available, answer the question like normal with your current knowledge ",
2339 "and do not tell the user about function calls\n</IMPORTANT>",
2340 "\n\nBe terse.<|im_end|>\n",
2341 "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
2342 "<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n",
2343 "</parameter>\n</function>\n</tool_call><|im_end|>\n",
2344 "<|im_start|>user\n<tool_response>\n{\"temp_c\": 21}\n</tool_response><|im_end|>\n",
2345 "<|im_start|>assistant\n<think>\n",
2346 );
2347 assert_eq!(s, expected);
2348 }
2349
2350 #[test]
2351 fn assistant_content_plus_calls_and_consecutive_tool_turns_group() {
2352 let turns = vec![
2353 Turn {
2354 role: "user".into(),
2355 content: "both".into(),
2356 tool_calls: Vec::new(),
2357 ..Default::default()
2358 },
2359 Turn {
2360 role: "assistant".into(),
2361 content: "checking".into(),
2362 tool_calls: vec![
2363 ToolCall {
2364 name: "a".into(),
2365 params: vec![("x".into(), "1".into())],
2366 ..Default::default()
2367 },
2368 ToolCall {
2369 name: "b".into(),
2370 params: Vec::new(),
2371 ..Default::default()
2372 },
2373 ],
2374 ..Default::default()
2375 },
2376 Turn {
2377 role: "tool".into(),
2378 content: "r1".into(),
2379 tool_calls: Vec::new(),
2380 ..Default::default()
2381 },
2382 Turn {
2383 role: "tool".into(),
2384 content: "r2".into(),
2385 tool_calls: Vec::new(),
2386 ..Default::default()
2387 },
2388 ];
2389 let s = apply_chat_template_tools(
2390 Some(QWEN_TOOLS_TMPL),
2391 &turns,
2392 false,
2393 &[],
2394 ThinkMode::Default,
2395 None,
2396 )
2397 .unwrap();
2398 assert_eq!(
2399 s,
2400 concat!(
2401 "<|im_start|>user\nboth<|im_end|>\n",
2402 "<|im_start|>assistant\nchecking\n\n",
2403 "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>\n",
2404 "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
2405 "<|im_start|>user\n<tool_response>\nr1\n</tool_response>",
2406 "\n<tool_response>\nr2\n</tool_response><|im_end|>\n",
2407 )
2408 );
2409 }
2410
2411 #[test]
2412 fn nothink_maps_to_enable_thinking_false_tail_and_degrades_gracefully() {
2413 let turns = vec![Turn {
2414 role: "user".into(),
2415 content: "hi".into(),
2416 tool_calls: Vec::new(),
2417 ..Default::default()
2418 }];
2419 // switch present: NoThink renders the closed think block.
2420 let s = apply_chat_template_tools(
2421 Some(QWEN_TOOLS_TMPL),
2422 &turns,
2423 true,
2424 &[],
2425 ThinkMode::NoThink,
2426 None,
2427 )
2428 .unwrap();
2429 assert!(
2430 s.ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"),
2431 "{s:?}"
2432 );
2433 // no enable_thinking switch: NoThink is ignored (template default stands).
2434 let tmpl_no_switch = "... add_generation_prompt ... '<think>\\n' ...";
2435 let s = apply_chat_template_tools(
2436 Some(tmpl_no_switch),
2437 &turns,
2438 true,
2439 &[],
2440 ThinkMode::NoThink,
2441 None,
2442 )
2443 .unwrap();
2444 assert!(s.ends_with("<|im_start|>assistant\n<think>\n"), "{s:?}");
2445 // no template at all: plain ChatML, no tail either way.
2446 let s =
2447 apply_chat_template_tools(None, &turns, true, &[], ThinkMode::NoThink, None).unwrap();
2448 assert!(s.ends_with("<|im_start|>assistant\n"), "{s:?}");
2449 }
2450
2451 #[test]
2452 fn tools_on_templates_without_tools_branch_error() {
2453 let turns = vec![Turn {
2454 role: "user".into(),
2455 content: "hi".into(),
2456 tool_calls: Vec::new(),
2457 ..Default::default()
2458 }];
2459 let tools = vec!["{}".to_string()];
2460 for tmpl in [None, Some("... hy_User ..."), Some("... <|turn> ...")] {
2461 let err =
2462 apply_chat_template_tools(tmpl, &turns, true, &tools, ThinkMode::Default, None);
2463 assert!(err.is_err(), "template={tmpl:?}");
2464 }
2465 // tool-role turns need the branch too.
2466 let tool_turns = vec![Turn {
2467 role: "tool".into(),
2468 content: "r".into(),
2469 tool_calls: Vec::new(),
2470 ..Default::default()
2471 }];
2472 assert!(
2473 apply_chat_template_tools(None, &tool_turns, true, &[], ThinkMode::Default, None)
2474 .is_err()
2475 );
2476 }
2477
2478 // ---- per-arch thinking control (owner directive 2026-08-07) -------------------------
2479 // Every `expected` below is the EXACT string the arch's REAL shipped template renders,
2480 // from research/step-sku-20260807/raw/thinking-goldens.txt (render-thinking-goldens.py:
2481 // jinja2 trim_blocks/lstrip_blocks over the pinned template dumps — gemma4 sha 36e3a42e
2482 // from the local QAT GGUF header, hy3 sha 7fc351fe from the pinned tencent/Hy3 snapshot).
2483
2484 fn one_user() -> Vec<Turn> {
2485 vec![turn("user", "Hi")]
2486 }
2487
2488 #[test]
2489 fn gemma4_thinking_maps_to_the_think_token_and_open_turn() {
2490 let g = |think: ThinkMode| {
2491 apply_chat_template_tools(Some("... <|turn> ..."), &one_user(), true, &[], think, None)
2492 .unwrap()
2493 };
2494 // Default AND NoThink = the template's own default(false): closed thought channel.
2495 // Byte-identical to the legacy renderer (no silent behavior change).
2496 let closed = "<|turn>user\nHi<turn|>\n<|turn>model\n<|channel>thought\n<channel|>";
2497 assert_eq!(g(ThinkMode::Default), closed);
2498 assert_eq!(g(ThinkMode::NoThink), closed);
2499 assert_eq!(
2500 apply_chat_template_str(Some("... <|turn> ..."), &[("user", "Hi")], true),
2501 closed,
2502 "legacy renderer = the default arm"
2503 );
2504 // Think = enable_thinking=true: <|think|> injected into a CREATED system turn and
2505 // the generation turn left open (golden: gemma4 enable_thinking=true, no system).
2506 assert_eq!(
2507 g(ThinkMode::Think),
2508 "<|turn>system\n<|think|>\n<turn|>\n<|turn>user\nHi<turn|>\n<|turn>model\n"
2509 );
2510 // with a client system turn the token lands at the very top of it (golden).
2511 let turns = vec![turn("system", "Be terse."), turn("user", "Hi")];
2512 let s = apply_chat_template_tools(
2513 Some("... <|turn> ..."),
2514 &turns,
2515 true,
2516 &[],
2517 ThinkMode::Think,
2518 None,
2519 )
2520 .unwrap();
2521 assert_eq!(
2522 s,
2523 "<|turn>system\n<|think|>\nBe terse.<turn|>\n\
2524 <|turn>user\nHi<turn|>\n<|turn>model\n"
2525 );
2526 }
2527
2528 /// A QAT-tooluse stand-in: carries `<|turn>` + `<|tool>` (engages the gemma4 tools arm)
2529 /// AND the closed-tail literal (the QAT trunk's thinking-off generation tail). The
2530 /// official served trunk omits that literal, so its tools arm emits the bare `<|turn>model`
2531 /// on thinking-off — the fixtures cover that side.
2532 const GEMMA_TOOLUSE_QAT_TMPL: &str =
2533 "... <|turn> ... <|tool> ... <|channel>thought\\n<channel|> ...";
2534
2535 #[test]
2536 fn gemma4_tools_arm_is_byte_identical_to_legacy_on_toolless_requests() {
2537 // REGRESSION (deliverable 6): a NO-tools request through the gemma4 tools arm renders
2538 // byte-identically to the standalone gemma4 renderer, across think modes and message
2539 // shapes — the tool path never perturbs plain gemma traffic on the tooluse trunk.
2540 let batteries: &[&[(&str, &str)]] = &[
2541 &[("user", "Hi")],
2542 &[("system", "Be terse."), ("user", "Weather?")],
2543 &[
2544 ("system", "rules"),
2545 ("user", "task"),
2546 ("assistant", "work"),
2547 ("user", "more"),
2548 ],
2549 &[("user", " padded "), ("assistant", "reply\nwith lines")],
2550 ];
2551 for msgs in batteries {
2552 let turns: Vec<Turn> = msgs
2553 .iter()
2554 .map(|(r, c)| Turn {
2555 role: r.to_string(),
2556 content: c.to_string(),
2557 ..Default::default()
2558 })
2559 .collect();
2560 for (mode, thinking) in [
2561 (ThinkMode::Default, false),
2562 (ThinkMode::NoThink, false),
2563 (ThinkMode::Think, true),
2564 ] {
2565 let legacy = apply_gemma4_template(msgs, true, thinking);
2566 let arm = apply_chat_template_tools(
2567 Some(GEMMA_TOOLUSE_QAT_TMPL),
2568 &turns,
2569 true,
2570 &[],
2571 mode,
2572 None,
2573 )
2574 .unwrap();
2575 assert_eq!(legacy, arm, "mode={mode:?} msgs={msgs:?}");
2576 }
2577 }
2578 }
2579
2580 #[test]
2581 fn gemma4_tools_arm_still_rejects_tools_without_the_tool_marker() {
2582 // a `<|turn>` template WITHOUT `<|tool>` keeps rejecting tool features with the clear
2583 // error (no committed tools reference for that trunk).
2584 let turns = vec![turn("user", "Weather?")];
2585 let tools = vec![r#"{"function":{"name":"f"}}"#.to_string()];
2586 let err = apply_chat_template_tools(
2587 Some("... <|turn> ..."),
2588 &turns,
2589 true,
2590 &tools,
2591 ThinkMode::Default,
2592 None,
2593 );
2594 assert!(err.is_err());
2595 }
2596
2597 #[test]
2598 fn hy3_thinking_maps_to_its_reasoning_effort_levels() {
2599 const HY_TMPL: Option<&str> = Some("... hy_User ...");
2600 let h = |think: ThinkMode, effort: Option<&str>| {
2601 apply_chat_template_tools(HY_TMPL, &one_user(), true, &[], think, effort).unwrap()
2602 };
2603 // Default AND NoThink = the template's own default: no_think header + CLOSED think.
2604 // Byte-identical to the legacy renderer.
2605 let closed = "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
2606 <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:no_think\
2607 <\u{ff5c}hy_User:opensource\u{ff5c}>Hi\
2608 <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
2609 <think:opensource></think:opensource>";
2610 assert_eq!(h(ThinkMode::Default, None), closed);
2611 assert_eq!(
2612 h(ThinkMode::NoThink, Some("low")),
2613 closed,
2614 "NoThink wins over a level: thinking off IS no_think"
2615 );
2616 assert_eq!(
2617 apply_chat_template_str(HY_TMPL, &[("user", "Hi")], true),
2618 closed,
2619 "legacy renderer = the default arm"
2620 );
2621 // Think at low/high = the template's own open-think levels (goldens: header carries
2622 // the level, generation prompt ends with an OPEN <think:opensource>).
2623 let low = h(ThinkMode::Think, Some("low"));
2624 assert!(low.contains("reasoning_effort:low"), "{low:?}");
2625 assert!(low.ends_with("<think:opensource>"), "{low:?}");
2626 let high = h(ThinkMode::Think, Some("high"));
2627 assert!(high.contains("reasoning_effort:high"), "{high:?}");
2628 assert!(high.ends_with("<think:opensource>"), "{high:?}");
2629 // medium clamps to low (hy3's accepted set is exactly no_think|low|high — the jinja
2630 // raise_exceptions on anything else); Think with no level also lands at low.
2631 assert_eq!(h(ThinkMode::Think, Some("medium")), low);
2632 assert_eq!(h(ThinkMode::Think, None), low);
2633 // History assistant turns stay CLOSED-think at every effort (the template opens only
2634 // turns past last_user_index; golden: "hy3 assistant history stays closed-think").
2635 let turns = vec![
2636 turn("user", "q"),
2637 turn("assistant", "a"),
2638 turn("user", "more"),
2639 ];
2640 let s =
2641 apply_chat_template_tools(HY_TMPL, &turns, true, &[], ThinkMode::Think, Some("low"))
2642 .unwrap();
2643 assert_eq!(
2644 s,
2645 "<\u{ff5c}hy_begin_of_sentence:opensource\u{ff5c}>\
2646 <\u{ff5c}reasoning_mode:opensource\u{ff5c}>reasoning_effort:low\
2647 <\u{ff5c}hy_User:opensource\u{ff5c}>q\
2648 <\u{ff5c}hy_Assistant:opensource\u{ff5c}>\
2649 <think:opensource></think:opensource>a\
2650 <\u{ff5c}hy_eos:opensource\u{ff5c}>\
2651 <\u{ff5c}hy_User:opensource\u{ff5c}>more\
2652 <\u{ff5c}hy_Assistant:opensource\u{ff5c}><think:opensource>"
2653 );
2654 }
2655
2656 #[test]
2657 fn qwen_think_mode_covers_all_three_directions() {
2658 let q = |think: ThinkMode| {
2659 apply_chat_template_tools(Some(QWEN_TOOLS_TMPL), &one_user(), true, &[], think, None)
2660 .unwrap()
2661 };
2662 // qwen's template default IS thinking-on, so Default and Think render identically.
2663 assert!(q(ThinkMode::Default).ends_with("<|im_start|>assistant\n<think>\n"));
2664 assert_eq!(q(ThinkMode::Think), q(ThinkMode::Default));
2665 assert!(q(ThinkMode::NoThink).ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"));
2666 }
2667
2668 // ---- StepFun Step-3.7-Flash (arch step35) -------------------------------------------
2669 // Every `expected` below is the EXACT string the shipped jinja renders, taken from
2670 // research/step37-p2-20260806/raw/step35-template-goldens.txt (generated by
2671 // render_step35_template.py under jinja2 with trim_blocks/lstrip_blocks — the settings HF
2672 // transformers and llama.cpp's minja use). `{{bos_token}}` renders as "" there because
2673 // encode(add_special) supplies BOS.
2674
2675 /// A step35 template stand-in: the real one is 5723 chars, and the detector keys on
2676 /// `render_message_content` (the macro no other committed template defines). The other
2677 /// markers are present to prove the step35 arm WINS the dispatch — a qwen-marker template
2678 /// carrying `<tools>`/`<think>`/`add_generation_prompt` would otherwise take the qwen arm.
2679 const STEP35_TMPL: &str = "{% macro render_message_content(message) %}... <tools> ... add_generation_prompt ... '<think>\\n' ...";
2680
2681 fn s35(msgs: &[(&str, &str)], genp: bool) -> String {
2682 apply_chat_template_str(Some(STEP35_TMPL), msgs, genp)
2683 }
2684
2685 fn s35_turns(turns: Vec<Turn>, genp: bool, tools: &[String]) -> String {
2686 apply_chat_template_tools(
2687 Some(STEP35_TMPL),
2688 &turns,
2689 genp,
2690 tools,
2691 ThinkMode::Default,
2692 None,
2693 )
2694 .unwrap()
2695 }
2696
2697 fn turn(role: &str, content: &str) -> Turn {
2698 Turn {
2699 role: role.into(),
2700 content: content.into(),
2701 tool_calls: Vec::new(),
2702 ..Default::default()
2703 }
2704 }
2705
2706 #[test]
2707 fn step35_plain_paths_match_the_shipped_jinja() {
2708 assert_eq!(
2709 s35(&[("user", "Hello")], true),
2710 "<|im_start|>user\nHello<|im_end|>\n<|im_start|>assistant\n<think>\n"
2711 );
2712 assert_eq!(
2713 s35(&[("user", "Hello")], false),
2714 "<|im_start|>user\nHello<|im_end|>\n"
2715 );
2716 assert_eq!(
2717 s35(&[("system", "You are helpful."), ("user", "Hi")], true),
2718 "<|im_start|>system\nYou are helpful.<|im_end|>\n\
2719 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2720 );
2721 // multi-turn: the prior assistant is BEFORE the last user query, so it carries NO
2722 // think block — the reasoning boundary the qwen arms have no concept of.
2723 assert_eq!(
2724 s35(
2725 &[
2726 ("system", "rules"),
2727 ("user", "task"),
2728 ("assistant", "work"),
2729 ("user", "more")
2730 ],
2731 true
2732 ),
2733 "<|im_start|>system\nrules<|im_end|>\n<|im_start|>user\ntask<|im_end|>\n\
2734 <|im_start|>assistant\nwork<|im_end|>\n<|im_start|>user\nmore<|im_end|>\n\
2735 <|im_start|>assistant\n<think>\n"
2736 );
2737 // content is NOT trimmed (this template applies no `|trim`) — the qwen arms trim.
2738 assert_eq!(
2739 s35(&[("user", " padded ")], true),
2740 "<|im_start|>user\n padded <|im_end|>\n<|im_start|>assistant\n<think>\n"
2741 );
2742 }
2743
2744 #[test]
2745 fn step35_dispatch_beats_the_qwen_marker_arm() {
2746 // The step35 template carries every qwen marker. If the dispatch order regressed, the
2747 // think tail would still be right and the BODY would be wrong (trimmed content, wrong
2748 // tools header) — so assert a body-shaped difference, not the tail.
2749 let qwen = apply_chat_template_str(Some(QWEN_TOOLS_TMPL), &[("user", " pad ")], true);
2750 let step = s35(&[("user", " pad ")], true);
2751 assert_eq!(
2752 qwen,
2753 "<|im_start|>user\npad<|im_end|>\n<|im_start|>assistant\n<think>\n"
2754 );
2755 assert_eq!(
2756 step,
2757 "<|im_start|>user\n pad <|im_end|>\n<|im_start|>assistant\n<think>\n"
2758 );
2759 assert_ne!(qwen, step);
2760 }
2761
2762 #[test]
2763 fn step35_reasoning_effort_renders_in_the_system_turn() {
2764 assert_eq!(
2765 apply_step35_template(&[turn("user", "Hi")], true, &[], Some("high")),
2766 "<|im_start|>system\nReasoning: high\n\n<|im_end|>\n\
2767 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2768 );
2769 assert_eq!(
2770 apply_step35_template(
2771 &[turn("system", "Be terse."), turn("user", "Hi")],
2772 true,
2773 &[],
2774 Some("low")
2775 ),
2776 "<|im_start|>system\nReasoning: low\n\nBe terse.<|im_end|>\n\
2777 <|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
2778 );
2779 // with tools the order flips: Reasoning, then the system content, then `# Tools`.
2780 let tools = vec![r#"{"type": "function", "function": {"name": "f"}}"#.to_string()];
2781 let s = apply_step35_template(
2782 &[turn("system", "Be terse."), turn("user", "q")],
2783 true,
2784 &tools,
2785 Some("medium"),
2786 );
2787 assert!(
2788 s.starts_with("<|im_start|>system\nReasoning: medium\n\nBe terse.\n\n# Tools\n"),
2789 "{s:?}"
2790 );
2791 }
2792
2793 #[test]
2794 fn reasoning_effort_reaches_step35_through_the_public_entry_and_only_step35() {
2795 // The serve path enters via apply_chat_template_tools: the level must land in the
2796 // rendered system turn on the step35 dialect...
2797 let turns = vec![turn("user", "Hi")];
2798 let s = apply_chat_template_tools(
2799 Some(STEP35_TMPL),
2800 &turns,
2801 true,
2802 &[],
2803 ThinkMode::Default,
2804 Some("high"),
2805 )
2806 .unwrap();
2807 assert!(
2808 s.starts_with("<|im_start|>system\nReasoning: high\n\n<|im_end|>\n"),
2809 "{s:?}"
2810 );
2811 // ...None keeps the template's own default (no Reasoning: line at all)...
2812 let s = apply_chat_template_tools(
2813 Some(STEP35_TMPL),
2814 &turns,
2815 true,
2816 &[],
2817 ThinkMode::Default,
2818 None,
2819 )
2820 .unwrap();
2821 assert!(!s.contains("Reasoning:"), "{s:?}");
2822 // ...and every non-step35 dialect ignores the parameter (their templates have no
2823 // reasoning_effort input) — byte-identical with and without it.
2824 for tmpl in [
2825 None,
2826 Some(QWEN_TOOLS_TMPL),
2827 Some("... hy_User ..."),
2828 Some("... <|turn> ..."),
2829 ] {
2830 let with = apply_chat_template_tools(
2831 tmpl,
2832 &turns,
2833 true,
2834 &[],
2835 ThinkMode::Default,
2836 Some("high"),
2837 )
2838 .unwrap();
2839 let without =
2840 apply_chat_template_tools(tmpl, &turns, true, &[], ThinkMode::Default, None)
2841 .unwrap();
2842 assert_eq!(with, without, "template={tmpl:?}");
2843 }
2844 }
2845
2846 #[test]
2847 fn step35_tools_header_is_not_the_qwen_header() {
2848 let tools = vec![
2849 r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string(),
2850 r#"{"type": "function", "function": {"name": "search"}}"#.to_string(),
2851 ];
2852 let s = s35_turns(
2853 vec![
2854 turn("system", "Be terse."),
2855 turn("user", "Weather in Paris?"),
2856 ],
2857 true,
2858 &tools,
2859 );
2860 assert_eq!(
2861 s,
2862 concat!(
2863 // leading system folds in BEFORE `# Tools` (the qwen arm appends it AFTER the
2864 // instruction block), and the header says "in JSONSchema format".
2865 "<|im_start|>system\nBe terse.\n\n# Tools\n\n",
2866 "You have access to the following functions in JSONSchema format:\n\n<tools>\n",
2867 "{\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}\n",
2868 "{\"type\": \"function\", \"function\": {\"name\": \"search\"}}\n</tools>",
2869 "\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:",
2870 "\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\n",
2871 "value_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the ",
2872 "second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
2873 // the nesting reminder carries literal \n...\n INSIDE the example tags, and the
2874 // Reminder list stops after 2 bullets (the qwen block has 4).
2875 "\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner ",
2876 "<function=...>\n...\n</function> block must be nested within <tool_call>\n...\n",
2877 "</tool_call> XML tags\n- Required parameters MUST be specified\n</IMPORTANT>",
2878 "<|im_end|>\n",
2879 "<|im_start|>user\nWeather in Paris?<|im_end|>\n",
2880 "<|im_start|>assistant\n<think>\n",
2881 )
2882 );
2883 // and it is NOT the qwen instruction block.
2884 assert!(!s.contains(QWEN_TOOLS_INSTRUCTION));
2885 }
2886
2887 #[test]
2888 fn step35_tool_results_take_their_own_role_and_group() {
2889 let tools =
2890 vec![r#"{"type": "function", "function": {"name": "get_weather"}}"#.to_string()];
2891 let turns = vec![
2892 turn("user", "both"),
2893 Turn {
2894 role: "assistant".into(),
2895 content: "checking".into(),
2896 tool_calls: vec![
2897 ToolCall {
2898 name: "a".into(),
2899 params: vec![("x".into(), "1".into())],
2900 ..Default::default()
2901 },
2902 ToolCall {
2903 name: "b".into(),
2904 params: Vec::new(),
2905 ..Default::default()
2906 },
2907 ],
2908 ..Default::default()
2909 },
2910 turn("tool", "r1"),
2911 turn("tool", "r2"),
2912 ];
2913 let s = s35_turns(turns, true, &tools);
2914 let body = s
2915 .split("<|im_end|>\n")
2916 .skip(1)
2917 .collect::<Vec<_>>()
2918 .join("<|im_end|>\n");
2919 assert_eq!(
2920 body,
2921 concat!(
2922 "<|im_start|>user\nboth<|im_end|>\n",
2923 // the assistant is AFTER the last user query, so it carries a think block — empty,
2924 // because its content has no `</think>` marker.
2925 "<|im_start|>assistant\n<think>\n\n</think>\nchecking",
2926 // NO separator before the first call and NONE between calls.
2927 "<tool_call>\n<function=a>\n<parameter=x>\n1\n</parameter>\n</function>\n</tool_call>",
2928 "<tool_call>\n<function=b>\n</function>\n</tool_call><|im_end|>\n",
2929 // own `tool_response` ROLE (not a user turn), and NO newlines inside the wrappers.
2930 "<|im_start|>tool_response\n<tool_response>r1</tool_response>",
2931 "<tool_response>r2</tool_response><|im_end|>\n",
2932 "<|im_start|>assistant\n<think>\n",
2933 )
2934 );
2935 }
2936
2937 #[test]
2938 fn step35_assistant_think_split_and_the_reasoning_boundary() {
2939 // inline <think>…</think> in content splits into the reasoning block + body.
2940 assert_eq!(
2941 s35(
2942 &[
2943 ("user", "q"),
2944 ("assistant", "<think>\nreasoned\n</think>\nanswer")
2945 ],
2946 false
2947 ),
2948 "<|im_start|>user\nq<|im_end|>\n\
2949 <|im_start|>assistant\n<think>\nreasoned\n</think>\nanswer<|im_end|>\n"
2950 );
2951 // no markers, but still after the last query -> an EMPTY reasoning block is emitted.
2952 assert_eq!(
2953 s35(&[("user", "q"), ("assistant", "plain")], false),
2954 "<|im_start|>user\nq<|im_end|>\n\
2955 <|im_start|>assistant\n<think>\n\n</think>\nplain<|im_end|>\n"
2956 );
2957 // a user turn that IS a <tool_response> wrapper does NOT move the boundary: the
2958 // assistant before it still counts as after-the-last-real-query.
2959 assert_eq!(
2960 s35(
2961 &[
2962 ("user", "real question"),
2963 ("assistant", "thinking about it"),
2964 ("user", "<tool_response>r</tool_response>")
2965 ],
2966 true
2967 ),
2968 "<|im_start|>user\nreal question<|im_end|>\n\
2969 <|im_start|>assistant\n<think>\n\n</think>\nthinking about it<|im_end|>\n\
2970 <|im_start|>user\n<tool_response>r</tool_response><|im_end|>\n\
2971 <|im_start|>assistant\n<think>\n"
2972 );
2973 }
2974
2975 #[test]
2976 fn step35_think_tail_is_unconditional_and_nothink_is_a_noop() {
2977 // No `enable_thinking` in this template, so ThinkMode::NoThink cannot close the tail —
2978 // the same graceful-no-op contract the other switchless templates get. A NoThink that
2979 // silently emitted `<think>\n\n</think>\n\n` would be a prompt the model never saw.
2980 let turns = vec![turn("user", "hi")];
2981 for mode in [ThinkMode::Default, ThinkMode::NoThink] {
2982 let s = apply_chat_template_tools(Some(STEP35_TMPL), &turns, true, &[], mode, None)
2983 .unwrap();
2984 assert!(
2985 s.ends_with("<|im_start|>assistant\n<think>\n"),
2986 "mode={mode:?} {s:?}"
2987 );
2988 }
2989 }
2990
2991 #[test]
2992 fn step35_plain_path_is_identical_through_both_renderers() {
2993 // same isolation contract the qwen arms hold: a plain request renders byte-identically
2994 // whether it enters via apply_chat_template_str or apply_chat_template_tools.
2995 let batteries: &[&[(&str, &str)]] = &[
2996 &[("user", "Hello")],
2997 &[("system", "You are helpful."), ("user", "Hi")],
2998 &[
2999 ("system", "rules"),
3000 ("user", "task"),
3001 ("assistant", "work"),
3002 ("user", "more"),
3003 ],
3004 &[("user", " padded "), ("assistant", "reply\nwith lines")],
3005 ];
3006 for msgs in batteries {
3007 let legacy = s35(msgs, true);
3008 let ext = s35_turns(msgs.iter().map(|(r, c)| turn(r, c)).collect(), true, &[]);
3009 assert_eq!(legacy, ext, "msgs={msgs:?}");
3010 }
3011 }
3012
3013 #[test]
3014 fn qwen_think_tail() {
3015 // a template string containing both markers triggers the <think> tail.
3016 let tmpl = "... add_generation_prompt ... '<think>\\n' ...";
3017 let s = apply_chat_template_str(
3018 Some(tmpl),
3019 &[("system", "You are helpful."), ("user", "Hi")],
3020 true,
3021 );
3022 assert_eq!(
3023 s,
3024 "<|im_start|>system\nYou are helpful.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\n"
3025 );
3026 }
3027
3028 /// The dsv4 effort-prefix law across BOTH encoding revisions (0731 re-gate,
3029 /// ENCODING-DIFF.md): the exact (thinking, effort, encoding) -> prefix table, including
3030 /// the refuse-on-ambiguity cells (unknown revision where the two encodings' bytes
3031 /// differ) and the never-corrupt clamps ("low"/"medium"/unknown levels -> no prefix).
3032 #[test]
3033 fn dsv4_effort_prefix_law() {
3034 use Dsv4Encoding::{Preview, V0731};
3035 let p = dsv4_effort_prefix;
3036 // chat mode: never a prefix, under any encoding or level (incl. unknown revision).
3037 for enc in [None, Some(Preview), Some(V0731)] {
3038 for eff in [None, Some("low"), Some("high"), Some("max")] {
3039 assert_eq!(p(false, eff, enc), Ok(""), "chat eff={eff:?} enc={enc:?}");
3040 }
3041 }
3042 // encoding-independent thinking cells: None/"low"/foreign levels -> no prefix.
3043 for enc in [None, Some(Preview), Some(V0731)] {
3044 assert_eq!(p(true, None, enc), Ok(""));
3045 assert_eq!(p(true, Some("low"), enc), Ok(""));
3046 assert_eq!(p(true, Some("medium"), enc), Ok(""));
3047 }
3048 // preview law: "high" == None (documented no-op), "max" -> the absolute text.
3049 assert_eq!(p(true, Some("high"), Some(Preview)), Ok(""));
3050 assert_eq!(
3051 p(true, Some("max"), Some(Preview)),
3052 Ok(DS_EFFORT_ABSOLUTE_MAX)
3053 );
3054 // 0731 law: "high" -> the absolute text (the OLD max), "max" -> the new beyond text.
3055 assert_eq!(
3056 p(true, Some("high"), Some(V0731)),
3057 Ok(DS_EFFORT_ABSOLUTE_MAX)
3058 );
3059 assert_eq!(p(true, Some("max"), Some(V0731)), Ok(DS_EFFORT_BEYOND_MAX));
3060 // ambiguity refusal: exactly the two cells whose bytes differ across revisions.
3061 assert!(p(true, Some("high"), None).is_err());
3062 assert!(p(true, Some("max"), None).is_err());
3063 // prefix text invariants pinned against the oracle constants: both end "\n\n",
3064 // both open with the ladder header, and they are distinct rungs.
3065 assert!(DS_EFFORT_ABSOLUTE_MAX.starts_with("Reasoning Effort: Absolute maximum"));
3066 assert!(DS_EFFORT_BEYOND_MAX.starts_with("Reasoning Effort: Beyond maximum \u{2014}"));
3067 assert!(DS_EFFORT_ABSOLUTE_MAX.ends_with("\n\n"));
3068 assert!(DS_EFFORT_BEYOND_MAX.ends_with("\n\n"));
3069 assert_ne!(DS_EFFORT_ABSOLUTE_MAX, DS_EFFORT_BEYOND_MAX);
3070 }
3071
3072 /// End-to-end through the dispatch: the same request renders per-revision prefixes, and
3073 /// an unknown revision refuses ONLY when the requested cell is ambiguous.
3074 #[test]
3075 fn dsv4_effort_renders_per_encoding_through_dispatch() {
3076 const DSV4_TMPL: &str = "<\u{ff5c}Assistant\u{ff5c}> \u{ff5c}DSML\u{ff5c}";
3077 let turns = vec![Turn {
3078 role: "user".into(),
3079 content: "Hi".into(),
3080 ..Default::default()
3081 }];
3082 let render = |effort: Option<&str>, enc: Option<Dsv4Encoding>| {
3083 apply_chat_template_tools_ex(
3084 Some(DSV4_TMPL),
3085 &turns,
3086 true,
3087 &[],
3088 &[],
3089 ThinkMode::Think,
3090 effort,
3091 enc,
3092 )
3093 };
3094 let base = render(None, None).unwrap();
3095 // preview: high is a no-op; max prefixes the absolute text right after BOS.
3096 assert_eq!(
3097 render(Some("high"), Some(Dsv4Encoding::Preview)).unwrap(),
3098 base
3099 );
3100 let pv_max = render(Some("max"), Some(Dsv4Encoding::Preview)).unwrap();
3101 assert_eq!(
3102 pv_max,
3103 format!("{DS_BOS}{DS_EFFORT_ABSOLUTE_MAX}{}", &base[DS_BOS.len()..])
3104 );
3105 // 0731: low == default; high == the preview's max bytes; max is the new text.
3106 let v_low = render(Some("low"), Some(Dsv4Encoding::V0731)).unwrap();
3107 assert_eq!(v_low, base);
3108 let v_high = render(Some("high"), Some(Dsv4Encoding::V0731)).unwrap();
3109 assert_eq!(v_high, pv_max);
3110 let v_max = render(Some("max"), Some(Dsv4Encoding::V0731)).unwrap();
3111 assert_eq!(
3112 v_max,
3113 format!("{DS_BOS}{DS_EFFORT_BEYOND_MAX}{}", &base[DS_BOS.len()..])
3114 );
3115 // unknown revision: unambiguous cells render, ambiguous cells refuse.
3116 assert_eq!(render(Some("low"), None).unwrap(), base);
3117 assert!(render(Some("high"), None).is_err());
3118 assert!(render(Some("max"), None).is_err());
3119 }
3120
3121 // ================= QWEN3.8 REASONING-EFFORT LADDER (lane/reasoning-schema-20260823) ======
3122 //
3123 // THE DEFECT: `reasoning_effort: low|medium|high` was accepted-and-ignored on every qwen3.8
3124 // request. The `effort_levels` cap probed for the substring `reasoning_effort is defined`,
3125 // and this template spells its input `reasoning_effort|default('xhigh')` — so the level was
3126 // parsed, validated, then dropped before the render, and the template's own `xhigh` default
3127 // never rendered either.
3128 //
3129 // THE GATE: memra's Rust renderer must reproduce the VENDOR's jinja byte-for-byte. The
3130 // template and the goldens are both committed; the goldens come from
3131 // `research/reasoning-schema-20260823/render_qwen38_goldens.py`, which renders the real
3132 // template under jinja2 with `trim_blocks`/`lstrip_blocks` — the settings HF transformers
3133 // and llama.cpp's minja both use, so the goldens are what the DEPLOYED template does.
3134 //
3135 // Lab authority (owner ruling 2026-08-23, "use the lab of the model, not a guess"): the
3136 // three rungs and both instruction sentences are Qwen's own — Qwen/Qwen3.8-27B's card
3137 // documents `reasoning_effort` as xhigh (default) | medium | low, and the sentences here are
3138 // that template's verbatim strings. `medium` injecting NOTHING is the vendor's choice, not a
3139 // gap. The served mint adds one thing the open-weights jinja lacks — a `high` -> `xhigh`
3140 // alias — which reproduces Qwen's own documented hosted-API mapping (high/max -> xhigh,
3141 // minimal -> low, none -> enable_thinking=False), so it is vendor semantics rather than ours.
3142 const Q38_TMPL: &str =
3143 include_str!("../../../research/reasoning-schema-20260823/qwen38-27b.chat_template.jinja");
3144
3145 fn q38(turns: &[Turn], think: ThinkMode, effort: Option<&str>, tools: &[String]) -> String {
3146 apply_chat_template_tools(Some(Q38_TMPL), turns, true, tools, think, effort)
3147 .expect("q38 render")
3148 }
3149
3150 #[test]
3151 fn qwen38_effort_ladder_reproduces_the_vendor_jinja_byte_for_byte() {
3152 let plain = [turn("user", "hi")];
3153 let with_system = [turn("system", "You are terse."), turn("user", "hi")];
3154 let empty_system = [turn("system", ""), turn("user", "hi")];
3155 // TWO leading system turns: the vendor MERGES the run into one turn joined by `\n`. This
3156 // server produces the shape itself (it normalizes `developer` to `system`), and the
3157 // historical per-turn emission diverged from the template here.
3158 let two_system = [
3159 turn("system", "rules"),
3160 turn("system", "dev rules"),
3161 turn("user", "hi"),
3162 ];
3163 let multiturn = [
3164 turn("user", "hi"),
3165 turn("assistant", "hello there"),
3166 turn("user", "again"),
3167 ];
3168 let multiturn_reasoned = [
3169 turn("user", "hi"),
3170 Turn {
3171 reasoning: Some("the user greets; greet back".into()),
3172 ..turn("assistant", "hello there")
3173 },
3174 turn("user", "again"),
3175 ];
3176 // (golden name, turns, think, effort) -> the jinja's own output.
3177 let cases: &[(&str, &[Turn], ThinkMode, Option<&str>)] = &[
3178 // THE LADDER, thinking on. `None` is the template's `default('xhigh')`.
3179 ("plain_default", &plain, ThinkMode::Default, None),
3180 ("plain_xhigh", &plain, ThinkMode::Think, Some("high")),
3181 ("plain_medium", &plain, ThinkMode::Think, Some("medium")),
3182 ("plain_low", &plain, ThinkMode::Think, Some("low")),
3183 // a leading system turn: the sentence PREPENDS it across a blank line.
3184 ("system_default", &with_system, ThinkMode::Default, None),
3185 ("system_xhigh", &with_system, ThinkMode::Think, Some("high")),
3186 (
3187 "system_medium",
3188 &with_system,
3189 ThinkMode::Think,
3190 Some("medium"),
3191 ),
3192 ("system_low", &with_system, ThinkMode::Think, Some("low")),
3193 // A system turn with NO content: the sentence renders ALONE. An unconditional
3194 // separator would leave a stray blank line before `<|im_end|>`.
3195 (
3196 "empty_system_low",
3197 &empty_system,
3198 ThinkMode::Think,
3199 Some("low"),
3200 ),
3201 (
3202 "two_system_xhigh",
3203 &two_system,
3204 ThinkMode::Think,
3205 Some("high"),
3206 ),
3207 ("two_system_off", &two_system, ThinkMode::NoThink, None),
3208 // THE BINARY AXIS: thinking off carries NO effort sentence, even with a level
3209 // named, because the template wraps the whole block in `enable_thinking is true`.
3210 ("plain_off", &plain, ThinkMode::NoThink, None),
3211 (
3212 "plain_off_with_level",
3213 &plain,
3214 ThinkMode::NoThink,
3215 Some("low"),
3216 ),
3217 ("system_off", &with_system, ThinkMode::NoThink, None),
3218 // MULTI-TURN: the template's preserve_thinking DEFAULT replays every prior
3219 // assistant turn's <think> block — empty when the client sent no reasoning,
3220 // the client's reasoning_content|trim when it did. These are the bytes the
3221 // reuse pools' text tier matches a parked stream against
3222 // (lane/dflash2-session-reuse).
3223 ("multiturn_off", &multiturn, ThinkMode::NoThink, None),
3224 ("multiturn_default", &multiturn, ThinkMode::Default, None),
3225 (
3226 "multiturn_reasoned_off",
3227 &multiturn_reasoned,
3228 ThinkMode::NoThink,
3229 None,
3230 ),
3231 ];
3232 for (name, turns, think, effort) in cases {
3233 let golden = golden(name);
3234 let got = q38(turns, *think, *effort, &[]);
3235 assert_eq!(
3236 got, golden,
3237 "{name}: memra's render diverges from the vendor's own jinja.\n\
3238 got: {got:?}\nwanted: {golden:?}"
3239 );
3240 }
3241 }
3242
3243 #[test]
3244 fn qwen38_effort_ladder_holds_on_the_tools_branch_too() {
3245 // The sentence goes BEFORE the `# Tools` header inside the one system turn. A separate
3246 // arm because the tools branch builds that turn on a different code path, and an effort
3247 // control honoured only on plain requests is the same defect wearing a different hat.
3248 let plain = [turn("user", "hi")];
3249 let tools = vec![
3250 concat!(
3251 r#"{"type": "function", "function": {"name": "get_weather", "#,
3252 r#""description": "Get the weather", "parameters": {"type": "object", "#,
3253 r#""properties": {"city": {"type": "string"}}, "required": ["city"]}}}"#
3254 )
3255 .to_string(),
3256 ];
3257 let two_system = [
3258 turn("system", "rules"),
3259 turn("system", "dev rules"),
3260 turn("user", "hi"),
3261 ];
3262 for (name, turns, think, effort) in [
3263 ("tools_default", &plain[..], ThinkMode::Default, None),
3264 ("tools_xhigh", &plain[..], ThinkMode::Think, Some("high")),
3265 ("tools_medium", &plain[..], ThinkMode::Think, Some("medium")),
3266 ("tools_low", &plain[..], ThinkMode::Think, Some("low")),
3267 // the leading system RUN folds into the tools header, merged — not leaked out as a
3268 // second body system turn.
3269 (
3270 "two_system_tools_low",
3271 &two_system[..],
3272 ThinkMode::Think,
3273 Some("low"),
3274 ),
3275 ] {
3276 let golden = golden(name);
3277 let got = q38(turns, think, effort, &tools);
3278 assert_eq!(
3279 got, golden,
3280 "{name}: tools-branch render diverges from the vendor's own jinja.\n\
3281 got: {got:?}\nwanted: {golden:?}"
3282 );
3283 }
3284 }
3285
3286 #[test]
3287 fn qwen38_ladder_rungs_are_distinct_prompts_and_medium_is_the_neutral_one() {
3288 // The owner's standard: a level that returns 200 must have an EFFECT, and any gradation
3289 // must be real. Effect here is measured the only way that cannot lie — prompt bytes.
3290 let plain = [turn("user", "hi")];
3291 let r = |effort: Option<&str>| q38(&plain, ThinkMode::Think, effort, &[]);
3292 let xhigh = r(Some("high"));
3293 let medium = r(Some("medium"));
3294 let low = r(Some("low"));
3295 assert_ne!(
3296 xhigh, medium,
3297 "xhigh and medium must not render the same prompt"
3298 );
3299 assert_ne!(xhigh, low, "xhigh and low must not render the same prompt");
3300 assert_ne!(
3301 medium, low,
3302 "medium and low must not render the same prompt"
3303 );
3304 // `medium` is the vendor's zero-steering rung: no sentence at all, so it renders exactly
3305 // what a bare ChatML request renders. THIS is what memra produced for EVERY q38 request
3306 // before this lane, at every effort level and at the default — which is why landing the
3307 // fix changes the default prompt (an operator `default_reasoning_effort: "medium"` keeps
3308 // it byte-identical to that history, and that is the documented no-op migration).
3309 assert!(
3310 !medium.contains("Reasoning effort is set to"),
3311 "medium must inject no sentence: {medium:?}"
3312 );
3313 assert_eq!(
3314 medium, "<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\n<think>\n",
3315 "medium is the pre-lane byte history for q38"
3316 );
3317 // The default is NOT medium — it is the vendor's xhigh. The serving-behaviour change.
3318 assert_eq!(
3319 r(None),
3320 xhigh,
3321 "an unset level is the template's own xhigh default"
3322 );
3323 assert!(
3324 xhigh.len() > medium.len() + 200,
3325 "xhigh adds a real instruction"
3326 );
3327 }
3328
3329 #[test]
3330 fn a_qwen_template_without_the_ladder_is_byte_identical_at_every_level() {
3331 // ORNITH, and the construction fact behind the server's TRANSLATION rule. Ornith AI
3332 // documents no graded effort anywhere (zero `reasoning_effort` occurrences across every
3333 // card in the org, both generations, all sizes; the entire control surface is one
3334 // `enable_thinking` guard). So the level has nothing to land on, and low/medium/high
3335 // render the SAME BYTES as an unset request. The server therefore folds a graded level
3336 // onto the binary axis as reasoning ON (coordinator ruling 2026-08-23 — stock codex and
3337 // Claude Code send `xhigh` on every request, and a caller who asked for reasoning and
3338 // gets reasoning has their promise kept); this test pins the byte-identity that makes
3339 // that translation honest rather than decorative.
3340 const ORNITH_TMPL: &str = include_str!(
3341 "../../../research/reasoning-schema-20260823/ornith15.chat_template.jinja"
3342 );
3343 let plain = [turn("user", "hi")];
3344 let r = |think: ThinkMode, effort: Option<&str>| {
3345 apply_chat_template_tools(Some(ORNITH_TMPL), &plain, true, &[], think, effort)
3346 .expect("ornith render")
3347 };
3348 let base = r(ThinkMode::Default, None);
3349 for level in ["low", "medium", "high"] {
3350 assert_eq!(
3351 r(ThinkMode::Think, Some(level)),
3352 base,
3353 "{level} must be byte-identical on a ladder-less template — the fact that makes \
3354 the graded->ON translation exact rather than approximate"
3355 );
3356 }
3357 // The BINARY axis is real here, and it is the one control ornith's lab defines.
3358 assert!(base.ends_with("<think>\n"), "{base:?}");
3359 assert!(
3360 r(ThinkMode::NoThink, None).ends_with("<think>\n\n</think>\n\n"),
3361 "ornith honours reasoning-off through its enable_thinking guard"
3362 );
3363 assert!(
3364 !base.contains("Reasoning effort is set to"),
3365 "the qwen3.8 sentence must NEVER leak onto a template that does not define it"
3366 );
3367 }
3368
3369 fn golden(name: &str) -> String {
3370 // Goldens live next to the generator that made them, so a reviewer can regenerate and
3371 // diff. `include_str!` rather than a runtime read: the path is checked at compile time,
3372 // so moving the fixture breaks the build instead of silently skipping the gate.
3373 macro_rules! g {
3374 ($($n:literal),* $(,)?) => {
3375 match name {
3376 $($n => include_str!(concat!(
3377 "../../../research/reasoning-schema-20260823/goldens/", $n, ".txt"
3378 )).to_string(),)*
3379 other => panic!("no golden named {other}"),
3380 }
3381 };
3382 }
3383 g!(
3384 "plain_default",
3385 "plain_xhigh",
3386 "plain_medium",
3387 "plain_low",
3388 "plain_off",
3389 "plain_off_with_level",
3390 "system_default",
3391 "system_xhigh",
3392 "system_medium",
3393 "system_low",
3394 "system_off",
3395 "empty_system_low",
3396 "two_system_xhigh",
3397 "two_system_off",
3398 "two_system_tools_low",
3399 "multiturn_off",
3400 "multiturn_default",
3401 "multiturn_reasoned_off",
3402 "tools_default",
3403 "tools_xhigh",
3404 "tools_medium",
3405 "tools_low",
3406 )
3407 }
3408}