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