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