Skip to main content

memra_tokenizer/
chat.rs

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