diff --git a/Cargo.lock b/Cargo.lock
index 8f42e8d..d48d4b3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -965,9 +965,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.3.0"
+version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
+checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2156,9 +2156,9 @@ checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9"
[[package]]
name = "either"
-version = "1.16.0"
+version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]]
name = "embassy-futures"
@@ -4816,9 +4816,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
-version = "1.15.0"
+version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
diff --git a/README.md b/README.md
index e62a45e..12e3e9b 100644
--- a/README.md
+++ b/README.md
@@ -91,7 +91,12 @@ It exists because two local-first apps — **[laurelane](https://github.com/phys
- **Template byte gate** — the hardcoded renderers are proven **byte-identical to
`transformers.apply_chat_template`** rendering the checkpoint's own imported `chat_template`
(via the `hf-chat-template` dev-dependency): plain, multi-turn, the full Hermes `# Tools` block,
- and function-call history all match byte-for-byte on Qwen3-0.6B (`tests/template_gate.rs`).
+ and function-call history match byte-for-byte on **Qwen3-0.6B, Qwen2.5-1.5B, and LFM2.5-1.2B**
+ (LFM's legs cover both tool conventions, its `tool` role turns, its history think-stripping, and
+ the standalone `chat_template.jinja` import path), `tests/template_gate.rs`. The known family
+ divergences are pinned to their exact deltas so any other drift fails loudly: Qwen2.5's no-system
+ branding preamble vs our neutral one, Qwen3's no-system no-preamble, and Qwen3's history
+ think-stripping (ours re-renders history verbatim — a `ChatMl::qwen3()` is a roadmap item).
Prompt JSON deliberately serializes with Python `json.dumps` spacing and insertion-order keys
(serde_json `preserve_order`) — the exact bytes the reference stack renders and models emit back.
- **f16 inference, validated** — Qwen2.5-1.5B runs coherently on `GpuF16` (weights + KV in f16, the
diff --git a/ROADMAP.md b/ROADMAP.md
index 7a34156..591f80b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -77,7 +77,7 @@ a benchmark holds/improves its budget; README perf claims link an artifact.
on an otherwise-idle GPU (5% util). Decode throughput tracking CPU availability is what a
dispatch-bound path looks like. Operationally: **run the budget gates on a quiet machine** or
they report contention as a regression.*
-- [ ] Evaluate Burn 0.21's `burn.toml` project config — per-subsystem tuning + a CubeCL kernel-validation
+- [x] Evaluate Burn 0.21's `burn.toml` project config — per-subsystem tuning + a CubeCL kernel-validation
layer without recompiling; useful as a debug switch for kernel-level parity hunts —
https://burn.dev/blog/release-0.21.0/ *(2026-07-17 research)* Concretely, a `burn.toml` dropped at
the project root parameterizes every internal subsystem with no code change / no recompile:
@@ -96,14 +96,21 @@ a benchmark holds/improves its budget; README perf claims link an artifact.
"enforce"|"validate"|"auto"`, `cache`; `[cubecl.streaming] max_streams`; `[cubecl.memory]
persistent_memory = "enabled"|"disabled"|"enforced"`. Validation is the `[cubecl.compilation]
check_mode` knob (`"validate"` = the OOB-catching layer). So the item's "ON for test / OFF for bench
- **profiles**" premise isn't directly expressible — one checked-in file applies to *every* cargo
- invocation, benches included. Workable routes when picked up: (a) accept global `check_mode = "validate"`
- only if a bench A/B shows the budgets are unmoved by it (measure the overhead first — it may be
- negligible and the whole ON/OFF split moot); (b) if it does move the budgets, drive it from an
- **env-var/CI-only file** (drop `burn.toml` in the test/CI working dir but not where `cargo bench` runs,
- or gate it behind a CI env var the bench job clears) rather than committing a repo-root file. Confirm
- whether Burn reads `burn.toml` from cwd or repo-root (determines whether the per-dir trick works) before
- adopting either.
+ **profiles**" premise isn't directly expressible in ONE file — the open question was whether the
+ discovery rule allows a per-directory split.
+ *(2026-07-24)* **Shipped — the per-directory split works.** The discovery rule read from
+ cubecl-runtime 0.10 source answers the 07-21 question: `RuntimeConfig::from_current_dir` walks UP
+ from the process CWD, `cubecl.toml` checked before `burn.toml` at each level, first hit wins — and
+ cargo runs each crate's tests/benches with CWD = the package dir. So a repo-root **`burn.toml`**
+ sets `[cubecl.compilation] check_mode = "validate"` (bounds-check every launch AND validate
+ explicitly-unchecked kernels for OOB) arming the `crates/mummu` parity/real-model suites, while
+ **`crates/mummu-bench/cubecl.toml`** opts the budget/bench crate back to the `auto` default so
+ recorded numbers never carry validation overhead. Consumers run from their own CWD — untouched.
+ Verified live: an A/B with a malformed root burn.toml makes a GPU test fail at config load naming
+ the bad key (proof of discovery + parse, from BOTH crates' CWDs), the real-model GPU suite passes
+ with validation armed (no OOB found — clean bill), and the budget gates hold their numbers from
+ the opted-out bench crate (12.4 tok/s with the files ≈ 12.1 without; first run after a config
+ change can read ~30% low while autotune re-tunes — re-run before believing a regression).
- [ ] Evaluate **CubeCL's now-complete flash-attention kernel** for the decode/prefill attention step —
the releases page reports a full implementation (causal **masking**, partitions, row-wise
reductions, multi-plane ops). Mummu currently materializes attention explicitly (q·kᵀ → f32 softmax
@@ -608,11 +615,27 @@ The subsystem that turns "a model on HuggingFace or on disk" into a loaded, pari
`{"name": "get_weather", ...}` from the new prompt bytes on the 4070 Ti SUPER.
Payoff (2) — a general fallback renderer for checkpoints without a hardcoded family renderer — is
split below.
+ *(2026-07-24, merged from the parallel nightly)* **Gate coverage extended beyond Qwen3**: the same
+ in-process harness now byte-verifies **Qwen2.5-1.5B** (plain / tools / FC history) and **LFM2.5-1.2B**
+ (plain±system, tools±system in the LFM bare-JSON convention, history think-stripping, pythonic
+ `<|tool_call_start|>` + `tool` role turns — LFM's legs also exercise the standalone
+ `chat_template.jinja` import fallback on the real checkpoint, whose template file was fetched into
+ the local cache). Known family divergences are PINNED to their exact deltas so any other drift still
+ fails: Qwen2.5's no-system branding preamble ("You are Qwen, …") vs our neutral one (with tools AND
+ the plain injected default turn), Qwen3's no-system no-preamble, Qwen3's history think-stripping.
- [ ] **General fallback chat renderer via `hf-chat-template`** — payoff (2) of the evaluation above: for a
checkpoint whose family has no hardcoded `chat` renderer, render prompts from its own imported
`chat_template` (the byte gate proved fidelity on Qwen3). Weigh promoting the dep from dev to optional
runtime feature vs the from-scratch ethos; needs the P8/consumer-facing API decision of when to prefer
the imported template over a family renderer. *(2026-07-23, split from the evaluation.)*
+- [ ] **`ChatMl::qwen3()` with history think-stripping** — the byte gate documented that Qwen3's template
+ strips `…` reasoning from assistant turns at/before the last user query while our
+ shared `ChatMl::qwen2()` renderer re-renders history verbatim (fine for fresh prompts + tool loops,
+ wrong for long multi-turn chats with a thinking Qwen3). A `qwen3()` constructor wants the LFM-style
+ strip (the machinery exists — `turn_content` already does it for `Lfm`) but keyed to Qwen3's
+ "at/before the last user query" rule rather than LFM's "every but the last assistant turn"; gate it
+ on the template byte gate's think case flipping from documented-divergence to byte-equal.
+ *(2026-07-24, found by the byte gate.)*
- [x] **Model registry / manifest** — a declarative `ModelSpec` (repo, architecture, weight format, dtype,
tokenizer, chat template, size tier) + a small built-in catalog of known-good models (Qwen2.5, LFM2.5,
MiniLM, …); adding a model = a manifest entry. *(2026-07-10) `mummu::registry`: `ModelSpec`
@@ -670,6 +693,11 @@ The subsystem that turns "a model on HuggingFace or on disk" into a loaded, pari
`<|tool_call_start|>[get_weather(city="Paris")]<|tool_call_end|>` from our rendered prompt and the
parser round-tripped it; the Qwen2 parity gate re-passed both legs after the template refactor
(max |Δlogit| 2.670e-5, Ollama greedy byte-identical). 16 new unit tests (109 total).*
+ *(2026-07-23/24) Template-embedded tool JSON now spells `json.dumps` separators (`python_json` —
+ `{"a": 1}`, not compact `{"a":1}`) in BOTH conventions, with insertion-order keys (serde_json
+ `preserve_order`, a workspace feature): the P3 template byte gate proved the checkpoints' own
+ templates (Jinja `tojson`) and the models' own emissions use that spelling, and the renders are
+ now byte-identical to `transformers.apply_chat_template` — see the P3 gate item.*
*(2026-07-10 research)* 2026 community numbers back the plan: Qwen3-8B keeps tool-calling score
through Q4_K_M (0.919 quantized vs 0.933 full — quant does NOT cost tool reliability, good news for
P9); BFCL shows a capability cliff below ~7B (Qwen3.5-9B 66.1% vs 4B 50.3%), so the zoo's
@@ -752,10 +780,35 @@ that fits the model AND uses every device to the fullest.
decide the policy explicitly — call `set_default_dtypes` per device at model-load/planner level (or pin
every runtime tensor creation site's dtype) so in-process mixed-precision is defined behavior, and add a
two-alias regression test once it is.
+- [ ] **Placement plan** — given model size + KV-cache + display headroom and the device set, choose a
**fit-and-fill** plan: single GPU when it fits; **shard layers across multiple GPUs** (pipeline/
layer-parallel over Burn's multi-device tensors — Burn gives the multi-device *primitives*, not automatic
tensor-parallel, so we place modules on devices ourselves); **spill cold layers to CPU** (GGUF-style
hybrid) when total VRAM is short. Largest-model-that-fits, every device busy.
+ *(2026-07-24, design settled)* **Heterogeneous per-device precision comes from ONE source file —
+ no new format.** The checkpoint stores weights once (bf16 safetensors, or a GGUF); each pipeline
+ stage *derives* its own in-memory representation at load: cast bf16→f32 for the big GPU and
+ bf16→f16 for a SHADER_F16-capable iGPU (both = the existing `CastFloatAdapter` path, quality-free
+ casts), and quantize int8/int4 for the CPU stage (the P9 keep-quantized leg; naive round-to-nearest
+ from bf16 is worse than a calibrated GPTQ/K-quant artifact, so prefer an on-the-fly *block-wise*
+ Q4_K-style quant — llama.cpp's offline K-quants are data-free, same math). The inverse also holds
+ and already runs: one Q4_K_M GGUF can serve f32/f16 stages by dequant/upcast (at Q4 quality) and
+ the CPU stage keep-quantized — so "one file" is a quality-vs-disk choice (bf16 = quality-max,
+ GGUF = size-min), never a format question. The only format-adjacent addition is a later
+ **derived-artifact cache** (don't re-quantize 30 layers per launch): a per-user cache dir of
+ ordinary safetensors/GGUF shards keyed by (source hash, dtype, layer range) — a cache, not a
+ format. The real work is runtime, in dependency order: (1) test the dtype-policy hazard above
+ *across* devices first — the flip is per-device, so f32-on-discrete + f16-on-iGPU in one process
+ is expected to work but is exactly the unproven experiment; (2) the stage-composed model type —
+ `CausalLm` is generic over ONE backend, a GPU+iGPU split can stay one `Wgpu` backend with
+ per-tensor dtypes (Burn 0.21 multi-dtype, the f32-softmax island already does per-tensor casts),
+ but the CPU stage is a different backend *type* (`burn-flex`), so the GPU→CPU seam is a
+ host-memory transfer between two backend generics; (3) activations cast at stage seams (small
+ tensors, cheap); (4) per-stage KV-cache shards + the micro-batch schedule (the multi-GPU item).
+ Expectation to encode in the planner: pipeline throughput = the slowest stage, so iGPU/CPU stages
+ exist to make a model FIT, not to make a fitting model faster — fit-and-fill, per-device precision
+ picked by what fits + what the device advertises (`inventory()` already records SHADER_F16 +
+ max_buffer_bytes + true VRAM per adapter).
- [ ] **Multi-GPU execution** — run the sharded plan: per-device sub-modules, activations handed across the
device boundary between stages, KV-cache per shard, and a micro-batch/pipeline schedule so the GPUs
overlap rather than idle. *(Tensor-parallel within a layer is the stretch goal; layer/pipeline split is
@@ -836,6 +889,16 @@ The VRAM lever the P6 planner pulls to make the largest useful model fit the use
the kernel substrate a Q4-weights × f16-activations decode path would ride (vs hand-writing a
dequant-fused kernel); gate any adoption on the parity harness + `bench/BASELINE.md` —
https://github.com/tracel-ai/cubecl/releases · https://burn.dev/blog/release-0.21.0/
+- [ ] **KV-cache quantization (FP8/e4m3)** — quantize the KV cache (and optionally the QK/ScoreV attention
+ matmuls) to 8-bit, halving per-token cache footprint — the *other* VRAM lever besides weights, and the
+ one that grows with context length. vLLM shipped exactly this (April 2026) and published the lessons
+ that transfer: uncalibrated per-head e4m3 scales recover 97%+ on reasoning tasks and 94–98% AUC at
+ 1M-token contexts; **two-level accumulation is critical** (intermediate f32 writes on long contexts —
+ the same failure our f32-softmax island guards); layer-selective beats uniform (sliding-window layers
+ pay overhead for no benefit); head_dim 256 loses at prefill (~1.6× register pressure) while 64/128 win.
+ For Mummu: our KV cache is f16 on `GpuF16` — an e4m3-quantized cache would halve it again; gate on the
+ parity harness + budgets like every numeric change. — https://vllm.ai/blog/2026-04-22-fp8-kvcache
+ *(2026-07-24 research)*
- [ ] **Auto-quantize-to-fit** — the planner picks the *highest* precision that fits the detected VRAM
(f16 → int8 → int4), reports the quality/size trade, and never silently ships a worse tier than asked.
diff --git a/burn.toml b/burn.toml
new file mode 100644
index 0000000..41309e7
--- /dev/null
+++ b/burn.toml
@@ -0,0 +1,17 @@
+# Burn/CubeCL project config (Burn 0.21+). Discovered at runtime by walking UP
+# from the process CWD (cubecl.toml is checked before burn.toml at each level),
+# so this file governs every process launched from inside this repo — i.e. the
+# `crates/mummu` test suites (cargo runs tests with CWD = the package dir, and
+# the walk-up lands here). Consumers of the library run from their own
+# directories and are untouched.
+#
+# `validate` = enforce bounds checks on EVERY kernel launch AND validate
+# explicitly-unchecked kernels for out-of-bounds accesses — the failure class
+# behind silent wrong-logits drift or a GPU crash. We want that armed under
+# the parity/real-model suites, where correctness is the whole point.
+#
+# The perf-budget/benchmark crate must NOT pay validation overhead in its
+# recorded numbers: `crates/mummu-bench/cubecl.toml` opts that crate back out
+# (its CWD finds the crate-local file first and stops the walk-up).
+[cubecl.compilation]
+check_mode = "validate"
diff --git a/crates/mummu-bench/cubecl.toml b/crates/mummu-bench/cubecl.toml
new file mode 100644
index 0000000..a1a3a62
--- /dev/null
+++ b/crates/mummu-bench/cubecl.toml
@@ -0,0 +1,9 @@
+# Opt the benchmark/budget crate OUT of the repo-root burn.toml's
+# `check_mode = "validate"`: CubeCL's config discovery walks up from the
+# process CWD and stops at the FIRST cubecl.toml/burn.toml it finds, and
+# cargo runs this crate's benches + budget-gate tests with CWD = this
+# directory. Bench numbers must never include kernel-validation overhead —
+# `auto` is CubeCL's default (bounds-check standard launches, trust
+# explicitly-unchecked ones).
+[compilation]
+check_mode = "auto"
diff --git a/crates/mummu/tests/template_gate.rs b/crates/mummu/tests/template_gate.rs
index acccb9c..23a5a21 100644
--- a/crates/mummu/tests/template_gate.rs
+++ b/crates/mummu/tests/template_gate.rs
@@ -219,3 +219,382 @@ fn qwen3_tool_history_render_byte_matches_the_imported_template() {
println!("{}", diff_context("tool-history", &ours, &reference));
assert_eq!(ours, reference, "FC history render must byte-match");
}
+
+// ---------------------------------------------------------------------------
+// Coverage beyond Qwen3 (2026-07-24): Qwen2.5 + LFM2.5 legs, and the known
+// family divergences PINNED to their exact deltas so any other drift fails.
+// Extra env keys: MUMMU_QWEN2_DIR, MUMMU_LFM2_DIR (LFM's legs also exercise
+// the standalone `chat_template.jinja` import fallback — the checkpoint dir
+// carries the template only as that file).
+// ---------------------------------------------------------------------------
+
+fn dir_from(var: &str) -> Option {
+ let dir = PathBuf::from(std::env::var_os(var)?);
+ dir.is_dir().then_some(dir)
+}
+
+fn weather_spec() -> ToolSpec {
+ ToolSpec {
+ name: "get_weather".into(),
+ description: "Get the current weather in a city.".into(),
+ parameters: serde_json::json!({
+ "type": "object",
+ "properties": {
+ "city": {"type": "string", "description": "City name"}
+ },
+ "required": ["city"]
+ }),
+ }
+}
+
+/// The Hermes wire JSON transformers hands the template as one `tools` entry.
+fn hermes_tool_json() -> serde_json::Value {
+ serde_json::json!({
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": "Get the current weather in a city.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "city": {"type": "string", "description": "City name"}
+ },
+ "required": ["city"]
+ }
+ }
+ })
+}
+
+/// LFM tools are BARE tool JSON (no Hermes wrapper) — the template runs
+/// `tool | tojson` on whatever is passed; the model card shows bare.
+fn lfm_tool_json() -> serde_json::Value {
+ serde_json::json!({
+ "name": "get_weather",
+ "description": "Get the current weather in a city.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "city": {"type": "string", "description": "City name"}
+ },
+ "required": ["city"]
+ }
+ })
+}
+
+/// LFM's template opens with `{{- bos_token -}}` — pass it in the context,
+/// exactly as transformers does from the tokenizer's special tokens.
+fn lfm_input(messages: Vec, tools: Vec) -> RenderInput {
+ let mut extra = serde_json::Map::new();
+ extra.insert(
+ "bos_token".into(),
+ serde_json::Value::String("<|startoftext|>".into()),
+ );
+ RenderInput {
+ messages,
+ tools,
+ add_generation_prompt: true,
+ extra,
+ ..RenderInput::default()
+ }
+}
+
+const SYSTEM: &str = "You are a helpful assistant.";
+const USER: &str = "List the first five prime numbers.";
+
+// ---- Qwen2.5 ---------------------------------------------------------------
+
+/// Qwen2.5's plain + tools + FC-history legs, byte-identical like Qwen3's.
+#[test]
+#[ignore = "needs the local Qwen2.5 checkpoint dir (MUMMU_QWEN2_DIR)"]
+fn qwen2_renders_byte_match_the_imported_template() {
+ let dir = dir_from("MUMMU_QWEN2_DIR").expect("set MUMMU_QWEN2_DIR");
+ let template = imported_template(&dir);
+
+ // Plain — the exact shape the Qwen2 parity gate commits.
+ let reference = template
+ .render(&RenderInput {
+ messages: vec![Message::system(SYSTEM), Message::user(USER)],
+ add_generation_prompt: true,
+ ..RenderInput::default()
+ })
+ .expect("reference render succeeds");
+ let ours = ChatMl::qwen2().render(&[Turn::system(SYSTEM), Turn::user(USER)]);
+ println!("{}", diff_context("qwen2 plain", &ours, &reference));
+ assert_eq!(ours, reference, "Qwen2.5 plain render must byte-match");
+
+ // Tools.
+ let reference = template
+ .render(&RenderInput {
+ messages: vec![Message::system(SYSTEM), Message::user(USER)],
+ tools: vec![hermes_tool_json()],
+ add_generation_prompt: true,
+ ..RenderInput::default()
+ })
+ .expect("reference render succeeds");
+ let ours = ChatMl::qwen2()
+ .render_with_tools(&[weather_spec()], &[Turn::system(SYSTEM), Turn::user(USER)]);
+ println!("{}", diff_context("qwen2 tools", &ours, &reference));
+ assert_eq!(ours, reference, "Qwen2.5 tools render must byte-match");
+
+ // FC history: a turn + two tool responses (merged user turn).
+ let mut assistant_call = Message::assistant("");
+ assistant_call.content = None;
+ assistant_call.tool_calls =
+ vec![serde_json::json!({"name": "get_weather", "arguments": {"city": "Paris"}})];
+ let reference = template
+ .render(&RenderInput {
+ messages: vec![
+ Message::system(SYSTEM),
+ Message::user("What's the weather in Paris?"),
+ assistant_call,
+ Message::new("tool", "{\"temp_c\": 21}"),
+ Message::new("tool", "{\"temp_c\": 24}"),
+ ],
+ tools: vec![hermes_tool_json()],
+ add_generation_prompt: true,
+ ..RenderInput::default()
+ })
+ .expect("reference render succeeds");
+ let calls = [mummu::chat::ToolCall {
+ name: "get_weather".into(),
+ arguments: serde_json::json!({"city": "Paris"}),
+ }];
+ let ours = ChatMl::qwen2().render_with_tools(
+ &[weather_spec()],
+ &[
+ Turn::system(SYSTEM),
+ Turn::user("What's the weather in Paris?"),
+ Turn::assistant_tool_calls(&calls),
+ Turn::tool_response("{\"temp_c\": 21}"),
+ Turn::tool_response("{\"temp_c\": 24}"),
+ ],
+ );
+ println!("{}", diff_context("qwen2 fc-history", &ours, &reference));
+ assert_eq!(ours, reference, "Qwen2.5 FC history render must byte-match");
+}
+
+/// Documented divergence, pinned exactly: without a system turn Qwen2.5's
+/// template injects its branding preamble where we inject a neutral one (one
+/// renderer serves Qwen2 AND Qwen3, and Qwen3 injects nothing — no single
+/// default can match both). The delta must be EXACTLY the preamble swap.
+#[test]
+#[ignore = "needs the local Qwen2.5 checkpoint dir (MUMMU_QWEN2_DIR)"]
+fn qwen2_no_system_defaults_diverge_only_by_the_documented_preamble() {
+ let dir = dir_from("MUMMU_QWEN2_DIR").expect("set MUMMU_QWEN2_DIR");
+ let template = imported_template(&dir);
+ const QWEN_PREAMBLE: &str =
+ "You are Qwen, created by Alibaba Cloud. You are a helpful assistant.";
+
+ // With tools: both sides synthesize a system turn; preambles differ.
+ let reference = template
+ .render(&RenderInput {
+ messages: vec![Message::user(USER)],
+ tools: vec![hermes_tool_json()],
+ add_generation_prompt: true,
+ ..RenderInput::default()
+ })
+ .expect("reference render succeeds");
+ let ours = ChatMl::qwen2().render_with_tools(&[weather_spec()], &[Turn::user(USER)]);
+ let ours_with_qwen_preamble = ours.replacen(SYSTEM, QWEN_PREAMBLE, 1);
+ assert_ne!(
+ ours_with_qwen_preamble, ours,
+ "our preamble must be present"
+ );
+ assert_eq!(
+ ours_with_qwen_preamble, reference,
+ "no-system tools render must diverge ONLY by the default preamble"
+ );
+
+ // Without tools: the template injects a whole default system turn; our
+ // render() injects nothing (explicit turns are the caller's contract).
+ let reference = template
+ .render(&RenderInput {
+ messages: vec![Message::user(USER)],
+ add_generation_prompt: true,
+ ..RenderInput::default()
+ })
+ .expect("reference render succeeds");
+ let ours = ChatMl::qwen2().render(&[Turn::user(USER)]);
+ assert_eq!(
+ format!("<|im_start|>system\n{QWEN_PREAMBLE}<|im_end|>\n{ours}"),
+ reference,
+ "no-system plain render must diverge ONLY by the injected default turn"
+ );
+}
+
+// ---- Qwen3 divergences ------------------------------------------------------
+
+/// Qwen3's documented divergences, pinned exactly: (a) with tools and no
+/// system turn its template injects NO preamble (ours injects the neutral
+/// one); (b) it strips `` reasoning from assistant turns at/before the
+/// last user query (ours re-renders history verbatim — `ChatMl::qwen3()` with
+/// the strip is a ROADMAP item; flip this case to byte-equal when it lands).
+#[test]
+#[ignore = "needs the local Qwen3 checkpoint dir (MUMMU_QWEN3_DIR)"]
+fn qwen3_divergences_are_exactly_the_documented_ones() {
+ let dir = dir().expect("set MUMMU_QWEN3_DIR to a Qwen3 checkpoint dir");
+ let template = imported_template(&dir);
+
+ let reference = template
+ .render(&RenderInput {
+ messages: vec![Message::user(USER)],
+ tools: vec![hermes_tool_json()],
+ add_generation_prompt: true,
+ ..RenderInput::default()
+ })
+ .expect("reference render succeeds");
+ let ours = ChatMl::qwen2().render_with_tools(&[weather_spec()], &[Turn::user(USER)]);
+ assert_eq!(
+ ours.replacen(&format!("{SYSTEM}\n\n"), "", 1),
+ reference,
+ "Qwen3 no-system tools render must diverge ONLY by our neutral preamble"
+ );
+
+ let think_turn = "2 then 3.The first primes are 2 and 3.";
+ let reference = template
+ .render(&RenderInput {
+ messages: vec![
+ Message::system(SYSTEM),
+ Message::user(USER),
+ Message::assistant(think_turn),
+ Message::user("And the next two?"),
+ ],
+ add_generation_prompt: true,
+ ..RenderInput::default()
+ })
+ .expect("reference render succeeds");
+ let ours = ChatMl::qwen2().render(&[
+ Turn::system(SYSTEM),
+ Turn::user(USER),
+ Turn::assistant(think_turn),
+ Turn::user("And the next two?"),
+ ]);
+ assert!(ours.contains(""), "ours re-renders history verbatim");
+ assert!(
+ !reference.contains(""),
+ "Qwen3's template strips history reasoning"
+ );
+ assert_eq!(
+ ours.replacen("2 then 3.", "", 1),
+ reference,
+ "the think block must be the ONLY delta"
+ );
+}
+
+// ---- LFM2.5 -----------------------------------------------------------------
+
+/// LFM2.5 plain legs (±system) — these also prove the standalone
+/// `chat_template.jinja` import fallback end-to-end on a real checkpoint:
+/// the dir ships NO `chat_template` JSON key, only the sibling file.
+#[test]
+#[ignore = "needs the local LFM2.5 checkpoint dir (MUMMU_LFM2_DIR) with chat_template.jinja"]
+fn lfm2_plain_renders_byte_match_the_imported_template() {
+ let dir = dir_from("MUMMU_LFM2_DIR").expect("set MUMMU_LFM2_DIR");
+ let template = imported_template(&dir);
+
+ let reference = template
+ .render(&lfm_input(vec![Message::user(USER)], vec![]))
+ .expect("reference render succeeds");
+ let ours = ChatMl::lfm2().render(&[Turn::user(USER)]);
+ println!("{}", diff_context("lfm plain", &ours, &reference));
+ assert_eq!(ours, reference, "LFM no-system render must byte-match");
+
+ let reference = template
+ .render(&lfm_input(
+ vec![Message::system(SYSTEM), Message::user(USER)],
+ vec![],
+ ))
+ .expect("reference render succeeds");
+ let ours = ChatMl::lfm2().render(&[Turn::system(SYSTEM), Turn::user(USER)]);
+ println!("{}", diff_context("lfm system", &ours, &reference));
+ assert_eq!(ours, reference, "LFM system render must byte-match");
+}
+
+/// LFM2.5 tools legs (±system, bare tool JSON): with no system turn BOTH
+/// sides inject nothing — LFM has no default preamble, so unlike the Qwen
+/// families this case is byte-equal, not a pinned divergence.
+#[test]
+#[ignore = "needs the local LFM2.5 checkpoint dir (MUMMU_LFM2_DIR) with chat_template.jinja"]
+fn lfm2_tools_renders_byte_match_the_imported_template() {
+ let dir = dir_from("MUMMU_LFM2_DIR").expect("set MUMMU_LFM2_DIR");
+ let template = imported_template(&dir);
+
+ let reference = template
+ .render(&lfm_input(
+ vec![Message::system(SYSTEM), Message::user(USER)],
+ vec![lfm_tool_json()],
+ ))
+ .expect("reference render succeeds");
+ let ours = ChatMl::lfm2()
+ .render_with_tools(&[weather_spec()], &[Turn::system(SYSTEM), Turn::user(USER)]);
+ println!("{}", diff_context("lfm tools+system", &ours, &reference));
+ assert_eq!(ours, reference, "LFM tools+system render must byte-match");
+
+ let reference = template
+ .render(&lfm_input(vec![Message::user(USER)], vec![lfm_tool_json()]))
+ .expect("reference render succeeds");
+ let ours = ChatMl::lfm2().render_with_tools(&[weather_spec()], &[Turn::user(USER)]);
+ println!("{}", diff_context("lfm tools no-system", &ours, &reference));
+ assert_eq!(
+ ours, reference,
+ "LFM tools no-system render must byte-match"
+ );
+}
+
+/// LFM2.5 history semantics: past assistant turns lose their ``
+/// reasoning on BOTH sides (keep_past_thinking=false), the LAST assistant
+/// turn keeps it; pythonic call turns + real `tool` role turns round-trip.
+#[test]
+#[ignore = "needs the local LFM2.5 checkpoint dir (MUMMU_LFM2_DIR) with chat_template.jinja"]
+fn lfm2_history_renders_byte_match_the_imported_template() {
+ let dir = dir_from("MUMMU_LFM2_DIR").expect("set MUMMU_LFM2_DIR");
+ let template = imported_template(&dir);
+
+ let past = "2, 3.\n\nThe first two primes are 2 and 3.";
+ let last = "5, 7 next.\n\n5 and 7.";
+ let turns = [
+ Turn::user(USER),
+ Turn::assistant(past),
+ Turn::user("And the next two?"),
+ Turn::assistant(last),
+ Turn::user("Thanks — one more?"),
+ ];
+ let messages = vec![
+ Message::user(USER),
+ Message::assistant(past),
+ Message::user("And the next two?"),
+ Message::assistant(last),
+ Message::user("Thanks — one more?"),
+ ];
+ let reference = template
+ .render(&lfm_input(messages, vec![]))
+ .expect("reference render succeeds");
+ let ours = ChatMl::lfm2().render(&turns);
+ println!("{}", diff_context("lfm think-strip", &ours, &reference));
+ assert_eq!(ours, reference, "LFM think-stripping must byte-match");
+
+ // Pythonic call turn + a real `tool` role turn: the LFM template renders
+ // assistant content verbatim, so the reference sees the emitted text.
+ let calls = [mummu::chat::ToolCall {
+ name: "get_weather".into(),
+ arguments: serde_json::json!({"city": "Paris"}),
+ }];
+ let call_turn = Turn::assistant_tool_calls_lfm(&calls);
+ let reference = template
+ .render(&lfm_input(
+ vec![
+ Message::user("Weather in Paris?"),
+ Message::assistant(call_turn.content.clone()),
+ Message::new("tool", "{\"temp_c\": 21}"),
+ ],
+ vec![],
+ ))
+ .expect("reference render succeeds");
+ let ours = ChatMl::lfm2().render(&[
+ Turn::user("Weather in Paris?"),
+ call_turn,
+ Turn::tool_response("{\"temp_c\": 21}"),
+ ]);
+ println!("{}", diff_context("lfm pythonic+tool", &ours, &reference));
+ assert_eq!(ours, reference, "LFM pythonic/tool turns must byte-match");
+}