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