Skip to main content

ferrum_cli/
source_resolver.rs

1//! CLI-level model source resolution.
2//!
3//! Centralises the lookup chain that `run` / `serve` / `bench` were
4//! reinventing each in their own copy:
5//!
6//!   1. **Curated GGUF alias** — resolve an explicit quantized alias to one
7//!      repository and filename.
8//!   2. **GGUF file path** — if the user passed an existing `*.gguf` file,
9//!      build a [`ResolvedModelSource`] directly without HF lookup.
10//!   3. **Local model dir** — if the path is an existing directory with
11//!      `config.json` + weights, treat it as a direct source.
12//!   4. **HF cache hit** — `~/.cache/huggingface/hub/models--<owner>--<repo>/snapshots/<rev>`.
13//!   5. **HF download** — fall back to [`HfDownloader`] (`run` / `serve`
14//!      only; `bench` callers may opt out).
15//!   6. **GPU-memory autosizing** — for GPU backends, run the chat
16//!      autosizer once on the resolved snapshot so `FERRUM_KV_MAX_BLOCKS`
17//!      etc. are populated before the engine starts.
18//!
19//! Before this module each command had its own `find_cached_model` /
20//! `detect_format` (some forked, some `pub fn`-imported across files),
21//! its own GGUF early-return, and its own autosize call site. The
22//! duplication caused subtle drift (e.g. `serve` accepting a non-existent
23//! `.gguf` path because it didn't call `looks_like_gguf_path` exactly the
24//! same way as `run`). All callers now go through
25//! [`resolve_model_source`].
26
27use std::path::{Path, PathBuf};
28
29/// Small, release-supported starter models used by CLI guidance. These are
30/// intentionally not implicit defaults: users should see what will download.
31pub const METAL_FIRST_SUCCESS_MODEL: &str = "qwen3.5:4b-q4_k_m";
32pub const CUDA_FIRST_SUCCESS_MODEL: &str = "qwen3.5:4b";
33
34/// Keep missing-model guidance identical for `run` and `serve`.
35pub fn first_success_model_help(command: &str) -> String {
36    format!(
37        "no model selected. Choose one explicitly:\n  Metal: ferrum {command} {METAL_FIRST_SUCCESS_MODEL}\n  CUDA:  ferrum {command} {CUDA_FIRST_SUCCESS_MODEL}\nRun `ferrum doctor` to inspect this binary before downloading a model."
38    )
39}
40use std::sync::Arc;
41
42use clap::Args;
43use ferrum_interfaces::vnext::{
44    ModelSourceKind, OriginalModelSource, OriginalModelSources, ProductModelSourceIdentity,
45};
46use ferrum_models::source::{ModelFormat, ResolvedModelSource};
47use ferrum_models::vnext::{
48    huggingface_snapshot_identity, open_registered_product_sources, ProductionModelSourceBundle,
49    ProductionWeightArtifact,
50};
51use ferrum_server::chat_template::ModelChatTemplate;
52use ferrum_types::{
53    EngineConfig, FerrumError, ModelId, ModelSource, Result, RuntimeConfigEntry,
54    RuntimeConfigSnapshot, RuntimeConfigSource,
55};
56use sha2::{Digest, Sha256};
57
58use crate::config::CliConfig;
59use crate::gpu_mem_autosize::{apply_auto_size_with_profile, AutoSizeProfile};
60
61/// Explicit role-specific model metadata sources shared by `run` and `serve`.
62/// The physical weight source remains the positional MODEL argument.
63#[derive(Args, Debug, Clone, Default)]
64pub struct ProductSourceArgs {
65    /// Directory containing the semantic `config.json` used to build the
66    /// typed model family. When set, it is also the tokenizer source unless
67    /// `--tokenizer-source` is supplied.
68    #[arg(long, value_name = "DIR")]
69    pub semantic_source: Option<PathBuf>,
70
71    /// Directory containing tokenizer files and the selected chat template.
72    #[arg(long, value_name = "DIR")]
73    pub tokenizer_source: Option<PathBuf>,
74}
75
76/// Resolve the single Hugging Face cache root used by product entrypoints.
77pub fn hf_cache_dir(config: &CliConfig) -> PathBuf {
78    if let Ok(hf_home) = std::env::var("HF_HOME") {
79        return PathBuf::from(hf_home);
80    }
81    PathBuf::from(shellexpand::tilde(&config.models.download.hf_cache_dir).as_ref())
82}
83
84/// Detect the on-disk format of a model directory or file.
85pub fn detect_format(path: &Path) -> ModelFormat {
86    if path.is_file()
87        && path
88            .extension()
89            .map(|e| e.eq_ignore_ascii_case("gguf"))
90            .unwrap_or(false)
91    {
92        return ModelFormat::GGUF;
93    }
94    if path.join("model.safetensors").exists() || path.join("model.safetensors.index.json").exists()
95    {
96        ModelFormat::SafeTensors
97    } else if path.join("pytorch_model.bin").exists() {
98        ModelFormat::PyTorchBin
99    } else {
100        ModelFormat::Unknown
101    }
102}
103
104/// True iff `model` is a path to an existing `*.gguf` file.
105pub fn looks_like_gguf_path(model: &str) -> bool {
106    let p = PathBuf::from(model);
107    p.extension()
108        .map(|e| e.eq_ignore_ascii_case("gguf"))
109        .unwrap_or(false)
110        && p.is_file()
111}
112
113/// Stable product-facing model id derived from one resolved source.
114///
115/// Repository models retain their canonical repository id. Direct local
116/// directories use the directory name, while GGUF files use the file stem.
117/// `run` and `serve` must use this helper rather than inventing entrypoint-
118/// specific ids for the same local source.
119pub fn public_model_id(source: &ResolvedModelSource) -> String {
120    if let Some(identity) = huggingface_snapshot_identity(&source.local_path) {
121        return identity.repository_id;
122    }
123    match source.format {
124        ModelFormat::GGUF => source
125            .local_path
126            .file_stem()
127            .map(|value| value.to_string_lossy().into_owned())
128            .unwrap_or_else(|| source.original.clone()),
129        _ if source.local_path == Path::new(&source.original) => source
130            .local_path
131            .file_name()
132            .map(|value| value.to_string_lossy().into_owned())
133            .unwrap_or_else(|| source.original.clone()),
134        _ => source.original.clone(),
135    }
136}
137
138/// Resolve an ergonomic model alias to its canonical Hugging Face model id.
139pub fn resolve_model_alias(name: &str) -> String {
140    match name.to_lowercase().as_str() {
141        "tinyllama" | "tiny" => "TinyLlama/TinyLlama-1.1B-Chat-v1.0".to_string(),
142        "qwen2.5:0.5b" | "qwen:0.5b" => "Qwen/Qwen2.5-0.5B-Instruct".to_string(),
143        "qwen2.5:1.5b" | "qwen:1.5b" => "Qwen/Qwen2.5-1.5B-Instruct".to_string(),
144        "qwen2.5:3b" | "qwen:3b" => "Qwen/Qwen2.5-3B-Instruct".to_string(),
145        "qwen2.5:7b" | "qwen:7b" => "Qwen/Qwen2.5-7B-Instruct".to_string(),
146        "qwen3:0.6b" => "Qwen/Qwen3-0.6B".to_string(),
147        "qwen3:1.7b" => "Qwen/Qwen3-1.7B".to_string(),
148        "qwen3:4b" => "Qwen/Qwen3-4B".to_string(),
149        "qwen3:14b" => "Qwen/Qwen3-14B".to_string(),
150        "qwen3:32b" => "Qwen/Qwen3-32B".to_string(),
151        "qwen3.5:4b" => "Qwen/Qwen3.5-4B".to_string(),
152        "qwen3-coder:30b" | "qwen3-coder:30b-a3b" => {
153            "Qwen/Qwen3-Coder-30B-A3B-Instruct".to_string()
154        }
155        "qwen3-coder:30b-gptq" => "jart25/Qwen3-Coder-30B-A3B-Instruct-Int4-gptq".to_string(),
156        "qwen3:14b-gptq" => "JunHowie/Qwen3-14B-GPTQ-Int4".to_string(),
157        "qwen3:32b-gptq" => "JunHowie/Qwen3-32B-GPTQ-Int4".to_string(),
158        "deepseek-r1:8b" | "r1:8b" => "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B".to_string(),
159        "deepseek-r1:14b" | "r1:14b" => "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B".to_string(),
160        "deepseek-r1:32b" | "r1:32b" => "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B".to_string(),
161        "deepseek-r1:32b-gptq" => "OPEA/DeepSeek-R1-Distill-Qwen-32B-int4-gptq-sym-inc".to_string(),
162        "qwen2.5-coder:32b" => "Qwen/Qwen2.5-Coder-32B-Instruct".to_string(),
163        "qwen2.5-coder:32b-gptq" => "Qwen/Qwen2.5-Coder-32B-Instruct-GPTQ-Int4".to_string(),
164        "qwen2.5-coder:14b" => "Qwen/Qwen2.5-Coder-14B-Instruct".to_string(),
165        "gemma3:1b" => "unsloth/gemma-3-1b-it".to_string(),
166        "gemma3:4b" => "unsloth/gemma-3-4b-it".to_string(),
167        "gemma3:27b" => "unsloth/gemma-3-27b-it".to_string(),
168        "gemma3:27b-gptq" => "circulus/gemma-3-27b-it-gptq".to_string(),
169        "mistral-small:24b" | "mistral-small:3.2" => {
170            "mistralai/Mistral-Small-3.2-24B-Instruct-2506".to_string()
171        }
172        "devstral:24b" | "devstral:2" => "mistralai/Devstral-Small-2-24B-Instruct-2512".to_string(),
173        "magistral:24b" => "mistralai/Magistral-Small-2509".to_string(),
174        "qwen2.5:3b-gptq" | "qwen2.5-3b-instruct-gptq-int4" => {
175            "Qwen/Qwen2.5-3B-Instruct-GPTQ-Int4".to_string()
176        }
177        "llama3.2:1b" => "meta-llama/Llama-3.2-1B-Instruct".to_string(),
178        "llama3.2:3b" => "meta-llama/Llama-3.2-3B-Instruct".to_string(),
179        "whisper-tiny" | "whisper:tiny" => "openai/whisper-tiny".to_string(),
180        "whisper-base" | "whisper:base" => "openai/whisper-base".to_string(),
181        "whisper-small" | "whisper:small" => "openai/whisper-small".to_string(),
182        "whisper-medium" | "whisper:medium" => "openai/whisper-medium".to_string(),
183        "whisper-large-v3" | "whisper:large-v3" => "openai/whisper-large-v3".to_string(),
184        "whisper-turbo" | "whisper:turbo" | "whisper-large-v3-turbo" => {
185            "openai/whisper-large-v3-turbo".to_string()
186        }
187        "qwen3-tts" | "tts" | "tts:0.6b" => "Qwen/Qwen3-TTS-12Hz-0.6B-Base".to_string(),
188        "tts:1.7b" | "qwen3-tts:1.7b" => "Qwen/Qwen3-TTS-12Hz-1.7B-Base".to_string(),
189        _ => name.to_string(),
190    }
191}
192
193struct GgufAliasEntry {
194    aliases: &'static [&'static str],
195    repo: &'static str,
196    filename: &'static str,
197    tokenizer_repo: Option<&'static str>,
198}
199
200const GGUF_ALIASES: &[GgufAliasEntry] = &[
201    GgufAliasEntry {
202        aliases: &["qwen3.5:4b-gguf", "qwen3.5:4b-q4_k_m"],
203        repo: "unsloth/Qwen3.5-4B-GGUF",
204        filename: "Qwen3.5-4B-Q4_K_M.gguf",
205        tokenizer_repo: Some("Qwen/Qwen3.5-4B"),
206    },
207    GgufAliasEntry {
208        aliases: &["qwen3.5:35b-a3b-gguf", "qwen3.5:35b-a3b-q4_k_s"],
209        repo: "unsloth/Qwen3.5-35B-A3B-GGUF",
210        filename: "Qwen3.5-35B-A3B-Q4_K_S.gguf",
211        tokenizer_repo: Some("Qwen/Qwen3.5-35B-A3B"),
212    },
213    GgufAliasEntry {
214        aliases: &["qwen3:8b-q4_k_m"],
215        repo: "Qwen/Qwen3-8B-GGUF",
216        filename: "Qwen3-8B-Q4_K_M.gguf",
217        tokenizer_repo: None,
218    },
219    GgufAliasEntry {
220        aliases: &["qwen3:4b-q4_k_m"],
221        repo: "Qwen/Qwen3-4B-GGUF",
222        filename: "Qwen3-4B-Q4_K_M.gguf",
223        tokenizer_repo: None,
224    },
225    GgufAliasEntry {
226        // Keep the unqualified `qwen3:1.7b` alias on safetensors. Quantized
227        // aliases must name their format so every product entrypoint resolves
228        // the same source.
229        aliases: &["qwen3:1.7b-gguf", "qwen3:1.7b-q8_0"],
230        repo: "Qwen/Qwen3-1.7B-GGUF",
231        filename: "Qwen3-1.7B-Q8_0.gguf",
232        tokenizer_repo: None,
233    },
234    GgufAliasEntry {
235        aliases: &["qwen3:0.6b-gguf", "qwen3:0.6b-q8_0"],
236        repo: "Qwen/Qwen3-0.6B-GGUF",
237        filename: "Qwen3-0.6B-Q8_0.gguf",
238        tokenizer_repo: None,
239    },
240    GgufAliasEntry {
241        aliases: &["qwen3-moe:30b-a3b-q4_k_m", "qwen3:30b-a3b-q4_k_m"],
242        repo: "Qwen/Qwen3-30B-A3B-GGUF",
243        filename: "Qwen3-30B-A3B-Q4_K_M.gguf",
244        tokenizer_repo: None,
245    },
246    GgufAliasEntry {
247        aliases: &["gemma3:1b-q4_k_m"],
248        repo: "unsloth/gemma-3-1b-it-GGUF",
249        filename: "gemma-3-1b-it-Q4_K_M.gguf",
250        tokenizer_repo: Some("unsloth/gemma-3-1b-it"),
251    },
252    GgufAliasEntry {
253        aliases: &["gemma3:27b-q4_k_m"],
254        repo: "unsloth/gemma-3-27b-it-GGUF",
255        filename: "gemma-3-27b-it-Q4_K_M.gguf",
256        tokenizer_repo: Some("unsloth/gemma-3-27b-it"),
257    },
258    GgufAliasEntry {
259        aliases: &["qwen3:14b-q4_k_m"],
260        repo: "Qwen/Qwen3-14B-GGUF",
261        filename: "Qwen3-14B-Q4_K_M.gguf",
262        tokenizer_repo: None,
263    },
264    GgufAliasEntry {
265        aliases: &["qwen3:32b-q4_k_m"],
266        repo: "Qwen/Qwen3-32B-GGUF",
267        filename: "Qwen3-32B-Q4_K_M.gguf",
268        tokenizer_repo: None,
269    },
270    GgufAliasEntry {
271        aliases: &["qwen3-coder:30b-q4_k_m", "qwen3-coder:30b-a3b-q4_k_m"],
272        repo: "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
273        filename: "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
274        tokenizer_repo: None,
275    },
276    GgufAliasEntry {
277        aliases: &["deepseek-r1:8b-q4_k_m", "r1:8b-q4_k_m"],
278        repo: "unsloth/DeepSeek-R1-0528-Qwen3-8B-GGUF",
279        filename: "DeepSeek-R1-0528-Qwen3-8B-Q4_K_M.gguf",
280        tokenizer_repo: None,
281    },
282    GgufAliasEntry {
283        aliases: &["deepseek-r1:32b-q4_k_m", "r1:32b-q4_k_m"],
284        repo: "unsloth/DeepSeek-R1-Distill-Qwen-32B-GGUF",
285        filename: "DeepSeek-R1-Distill-Qwen-32B-Q4_K_M.gguf",
286        tokenizer_repo: None,
287    },
288    GgufAliasEntry {
289        aliases: &["qwen2.5-coder:32b-q4_k_m"],
290        repo: "bartowski/Qwen2.5-Coder-32B-Instruct-GGUF",
291        filename: "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf",
292        tokenizer_repo: Some("Qwen/Qwen2.5-Coder-32B-Instruct"),
293    },
294    GgufAliasEntry {
295        aliases: &["mistral-small:24b-q4_k_m"],
296        repo: "bartowski/mistralai_Mistral-Small-3.2-24B-Instruct-2506-GGUF",
297        filename: "mistralai_Mistral-Small-3.2-24B-Instruct-2506-Q4_K_M.gguf",
298        tokenizer_repo: Some("unsloth/Mistral-Small-3.2-24B-Instruct-2506"),
299    },
300    GgufAliasEntry {
301        aliases: &["devstral:24b-q4_k_m"],
302        repo: "bartowski/mistralai_Devstral-Small-2-24B-Instruct-2512-GGUF",
303        filename: "mistralai_Devstral-Small-2-24B-Instruct-2512-Q4_K_M.gguf",
304        tokenizer_repo: Some("mistralai/Devstral-Small-2-24B-Instruct-2512"),
305    },
306    GgufAliasEntry {
307        aliases: &["magistral:24b-q4_k_m"],
308        repo: "bartowski/mistralai_Magistral-Small-2509-GGUF",
309        filename: "mistralai_Magistral-Small-2509-Q4_K_M.gguf",
310        tokenizer_repo: Some("unsloth/Magistral-Small-2509"),
311    },
312    GgufAliasEntry {
313        aliases: &["llama3.1:8b-q4_k_m"],
314        repo: "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF",
315        filename: "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
316        tokenizer_repo: Some("unsloth/Meta-Llama-3.1-8B-Instruct"),
317    },
318    GgufAliasEntry {
319        aliases: &["llama3.2:3b-q4_k_m"],
320        repo: "bartowski/Llama-3.2-3B-Instruct-GGUF",
321        filename: "Llama-3.2-3B-Instruct-Q4_K_M.gguf",
322        tokenizer_repo: Some("unsloth/Llama-3.2-3B-Instruct"),
323    },
324    GgufAliasEntry {
325        aliases: &["llama3.2:1b-q4_k_m"],
326        repo: "bartowski/Llama-3.2-1B-Instruct-GGUF",
327        filename: "Llama-3.2-1B-Instruct-Q4_K_M.gguf",
328        tokenizer_repo: Some("unsloth/Llama-3.2-1B-Instruct"),
329    },
330];
331
332/// Resolve a GGUF alias to its repository and exact quantized filename.
333pub fn resolve_gguf_alias(name: &str) -> Option<(String, String)> {
334    let name = name.to_lowercase();
335    GGUF_ALIASES
336        .iter()
337        .find(|entry| entry.aliases.contains(&name.as_str()))
338        .map(|entry| (entry.repo.to_string(), entry.filename.to_string()))
339}
340
341/// Resolve the tokenizer sidecar repository for a GGUF repository.
342pub fn tokenizer_sibling_repo(gguf_repo: &str) -> Option<String> {
343    if let Some(entry) = GGUF_ALIASES.iter().find(|entry| entry.repo == gguf_repo) {
344        if let Some(repo) = entry.tokenizer_repo {
345            return Some(repo.to_string());
346        }
347    }
348    gguf_repo.strip_suffix("-GGUF").map(str::to_string)
349}
350
351/// Chat-profile runtime defaults for `ferrum run`. Sniffs the arch
352/// (dense vs MoE — works for both GGUF files and safetensors snapshot
353/// dirs) and materializes missing compatibility env vars for:
354///
355///   - `FERRUM_KV_CAPACITY`     — 8192 dense / 4096 MoE
356///   - `FERRUM_PAGED_KV` / legacy `FERRUM_METAL_PAGED_KV` — 0 GGUF / 1 only
357///     for Qwen3 dense and MoE safetensors.
358///     The Metal Qwen3-MoE GGUF paged-KV decode path can repeat the first
359///     generated token until `max_tokens`; keep GGUF on the contiguous path
360///     until that kernel path is fixed. Qwen3 dense safetensors is validated
361///     on paged KV; TinyLlama/Llama and Qwen2 dense safetensors produce token
362///     noise on the Metal paged-KV path and default to contiguous KV.
363///   - `FERRUM_PAGED_MAX_SEQS=2` dense / `1` MoE, `FERRUM_MAX_BATCH=1` — single-user REPL.
364///     Keeps the paged pool at ~1.7 GB for `cap=8192` dense; without this
365///     cap the default `max_seqs=32` makes the pool ~30 GB on a 32 GB Mac.
366///   - `FERRUM_MOE_BATCHED=0`, `FERRUM_MOE_BATCHED_DECODE=0`,
367///     `FERRUM_MOE_BATCH_THRESHOLD=2` — MoE only. `run` is an interactive
368///     single-session path, so do not engage unneeded multi-sequence MoE
369///     batching.
370///
371/// Idempotent: if a user explicitly sets one of these env vars before
372/// invoking `ferrum run`, that value wins (we only set when unset).
373/// Called automatically by `resolve_model_source` when the autosize
374/// profile is `Chat`. Server/bench callers don't get these defaults —
375/// they don't fit the multi-turn REPL pattern this profile is tuned for.
376///
377/// Without this, dense safetensors models (e.g. `Qwen/Qwen3-0.6B`)
378/// inherit the model-level `DEFAULT_KV_CAPACITY=512` floor in
379/// `llama_family.rs::ensure_kv`, which overflows after ~512 tokens on
380/// a `max_tokens=2048` chat — manifesting as a `KV cache overflow on
381/// layer 0` panic mid-response.
382pub fn apply_chat_profile_env(snapshot_path: &Path) {
383    let entries = chat_profile_runtime_entries(
384        snapshot_path,
385        &RuntimeConfigSnapshot::capture_current(),
386        RuntimeConfigSource::Default,
387    );
388    crate::runtime_env::materialize_runtime_env_defaults(&entries);
389}
390
391pub fn chat_profile_runtime_entries(
392    snapshot_path: &Path,
393    current: &RuntimeConfigSnapshot,
394    source: RuntimeConfigSource,
395) -> Vec<RuntimeConfigEntry> {
396    let is_gguf = snapshot_path.is_file()
397        && snapshot_path
398            .extension()
399            .map(|e| e.eq_ignore_ascii_case("gguf"))
400            .unwrap_or(false);
401    let is_moe = detect_moe_arch(snapshot_path);
402    let model_family = detect_model_family(snapshot_path);
403    chat_profile_runtime_entries_for_arch(is_gguf, is_moe, model_family.as_deref(), current, source)
404}
405
406fn chat_profile_runtime_entries_for_arch(
407    is_gguf: bool,
408    is_moe: bool,
409    model_family: Option<&str>,
410    current: &RuntimeConfigSnapshot,
411    source: RuntimeConfigSource,
412) -> Vec<RuntimeConfigEntry> {
413    let mut entries = Vec::new();
414
415    push_missing_entry(
416        &mut entries,
417        current,
418        "FERRUM_KV_CAPACITY",
419        if is_moe { "4096" } else { "8192" },
420        source,
421    );
422    // GGUF: contiguous path is the correctness baseline. For safetensors,
423    // keep paged KV only on families with current product evidence.
424    let need_paged = !is_gguf
425        && (is_moe
426            || model_family.is_some_and(|family| {
427                family.eq_ignore_ascii_case("qwen3") || family.eq_ignore_ascii_case("qwen3_5")
428            }));
429    push_paged_kv_compat_entries(
430        &mut entries,
431        current,
432        if need_paged { "1" } else { "0" },
433        source,
434    );
435    // Single-user REPL pool sizing — dense safetensors keeps a spare
436    // sequence, while MoE uses one long interactive session. Keeping MoE at
437    // 2048 tokens forces Qwen3-30B-A3B thinking-mode chats to shrink answers
438    // after a few turns.
439    if !is_gguf || is_moe {
440        for (k, v) in [
441            ("FERRUM_PAGED_MAX_SEQS", if is_moe { "1" } else { "2" }),
442            ("FERRUM_MAX_BATCH", "1"),
443        ] {
444            push_missing_entry(&mut entries, current, k, v, source);
445        }
446    }
447    if is_moe {
448        for (k, v) in [
449            ("FERRUM_MOE_BATCHED", "0"),
450            ("FERRUM_MOE_BATCHED_DECODE", "0"),
451            ("FERRUM_MOE_BATCH_THRESHOLD", "2"),
452        ] {
453            push_missing_entry(&mut entries, current, k, v, source);
454        }
455    }
456    entries
457}
458
459fn push_missing_entry(
460    entries: &mut Vec<RuntimeConfigEntry>,
461    current: &RuntimeConfigSnapshot,
462    key: &str,
463    value: &str,
464    source: RuntimeConfigSource,
465) {
466    if snapshot_value(current, key).is_none() {
467        entries.push(RuntimeConfigEntry::new(key, value, source));
468    }
469}
470
471fn push_paged_kv_compat_entries(
472    entries: &mut Vec<RuntimeConfigEntry>,
473    current: &RuntimeConfigSnapshot,
474    value: &str,
475    source: RuntimeConfigSource,
476) {
477    let effective_value = snapshot_value(current, "FERRUM_PAGED_KV")
478        .or_else(|| snapshot_value(current, "FERRUM_METAL_PAGED_KV"))
479        .unwrap_or(value);
480    push_missing_entry(entries, current, "FERRUM_PAGED_KV", effective_value, source);
481    push_missing_entry(
482        entries,
483        current,
484        "FERRUM_METAL_PAGED_KV",
485        effective_value,
486        source,
487    );
488}
489
490fn snapshot_value<'a>(snapshot: &'a RuntimeConfigSnapshot, key: &str) -> Option<&'a str> {
491    snapshot
492        .entries
493        .iter()
494        .find(|entry| entry.key == key)
495        .map(|entry| entry.effective_value.as_str())
496}
497
498/// Detect whether `path` is a Mixture-of-Experts model. Handles both a
499/// `.gguf` file (peek `general.architecture` from GGUF metadata) and a
500/// safetensors snapshot directory (read `config.json` and match
501/// `architectures` / `model_type` against `moe`, case-insensitive).
502pub fn detect_moe_arch(path: &Path) -> bool {
503    use ferrum_quantization::gguf::GgufFile;
504
505    if path.is_file()
506        && path
507            .extension()
508            .map(|e| e.eq_ignore_ascii_case("gguf"))
509            .unwrap_or(false)
510    {
511        return GgufFile::open(path)
512            .ok()
513            .and_then(|g| g.architecture().ok().map(|s| s.to_string()))
514            .map(|a| a.to_lowercase().contains("moe"))
515            .unwrap_or(false);
516    }
517
518    let config_path = path.join("config.json");
519    let Ok(contents) = std::fs::read_to_string(&config_path) else {
520        return false;
521    };
522    let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
523        return false;
524    };
525    if let Some(archs) = json.get("architectures").and_then(|v| v.as_array()) {
526        if archs
527            .iter()
528            .any(|a| a.as_str().is_some_and(|s| s.to_lowercase().contains("moe")))
529        {
530            return true;
531        }
532    }
533    json.get("model_type")
534        .and_then(|v| v.as_str())
535        .is_some_and(|mt| mt.to_lowercase().contains("moe"))
536}
537
538pub fn detect_model_family(path: &Path) -> Option<String> {
539    use ferrum_quantization::gguf::GgufFile;
540
541    if path.is_file()
542        && path
543            .extension()
544            .map(|e| e.eq_ignore_ascii_case("gguf"))
545            .unwrap_or(false)
546    {
547        return GgufFile::open(path)
548            .ok()
549            .and_then(|g| g.architecture().ok().map(|s| normalize_model_family(s)));
550    }
551
552    let config_path = path.join("config.json");
553    let contents = std::fs::read_to_string(&config_path).ok()?;
554    let json = serde_json::from_str::<serde_json::Value>(&contents).ok()?;
555    if let Some(model_type) = json.get("model_type").and_then(|v| v.as_str()) {
556        return Some(normalize_model_family(model_type));
557    }
558    json.get("architectures")
559        .and_then(|v| v.as_array())
560        .and_then(|archs| archs.iter().find_map(|arch| arch.as_str()))
561        .map(normalize_model_family)
562}
563
564fn normalize_model_family(raw: &str) -> String {
565    let lower = raw.to_ascii_lowercase();
566    if lower.contains("qwen3_5_moe")
567        || lower.contains("qwen3_5moe")
568        || lower.contains("qwen35_moe")
569        || lower.contains("qwen35moe")
570    {
571        "qwen3_5_moe".to_string()
572    } else if lower.contains("qwen3_5") || lower.contains("qwen35") {
573        "qwen3_5".to_string()
574    } else if lower.contains("qwen3_moe")
575        || lower.contains("qwen3moe")
576        || lower.contains("qwen3_mo")
577    {
578        "qwen3_moe".to_string()
579    } else if lower.contains("qwen3") {
580        "qwen3".to_string()
581    } else if lower.contains("qwen2") || lower == "qwen" {
582        "qwen2".to_string()
583    } else if lower.contains("mistral") {
584        "mistral".to_string()
585    } else if lower.contains("llama") || lower.contains("tinyllama") {
586        "llama".to_string()
587    } else {
588        lower
589    }
590}
591
592/// Correctness fallback for Metal GGUF MoE.
593///
594/// The device-side prefill MoE top-k/bucketing path currently produces
595/// incorrect first-token logits for Qwen3-30B-A3B GGUF on Metal. The host
596/// top-k path is slower, but is the validated product path until the Metal
597/// GPU router is fixed. Keep this scoped to Metal + GGUF + MoE and let an
598/// explicit `FERRUM_MOE_HOST_TOPK` env/config value win for diagnostics.
599pub fn metal_gguf_moe_correctness_entries(
600    snapshot_path: &Path,
601    device: &ferrum_types::Device,
602    current: &RuntimeConfigSnapshot,
603    source: RuntimeConfigSource,
604) -> Vec<RuntimeConfigEntry> {
605    let is_gguf = snapshot_path.is_file()
606        && snapshot_path
607            .extension()
608            .map(|e| e.eq_ignore_ascii_case("gguf"))
609            .unwrap_or(false);
610    if !is_gguf || !device_is_metal(device) || !detect_moe_arch(snapshot_path) {
611        return Vec::new();
612    }
613
614    let mut entries = Vec::new();
615    push_missing_entry(&mut entries, current, "FERRUM_MOE_HOST_TOPK", "1", source);
616    entries
617}
618
619/// Product defaults for `ferrum serve` on GGUF LLMs.
620///
621/// GGUF paths do not go through the HF-directory autosizer. Without an
622/// explicit profile, Qwen3-30B-A3B falls back to the model default
623/// `FERRUM_KV_CAPACITY=512`, which can make a normal sequence of OpenAI
624/// requests (sync correctness, multi-turn, then stream) end the stream
625/// immediately with an empty EOS. Keep this product path correct by default:
626/// enough context for Qwen3 thinking-mode responses and a multi-request pool
627/// for dense and MoE GGUF serving. A registered vNext execution plan owns its
628/// context capacity through admission and the dynamic resource pool, so the
629/// legacy static capacity guard must not override it.
630pub fn serve_profile_runtime_entries(
631    snapshot_path: &Path,
632    device: &ferrum_types::Device,
633    vnext_plan_owns_context_capacity: bool,
634    current: &RuntimeConfigSnapshot,
635    source: RuntimeConfigSource,
636) -> Vec<RuntimeConfigEntry> {
637    let is_gguf = snapshot_path.is_file()
638        && snapshot_path
639            .extension()
640            .map(|e| e.eq_ignore_ascii_case("gguf"))
641            .unwrap_or(false);
642    serve_profile_runtime_entries_for_arch(
643        is_gguf,
644        detect_moe_arch(snapshot_path),
645        device_is_metal(device),
646        vnext_plan_owns_context_capacity,
647        current,
648        source,
649    )
650}
651
652fn device_is_metal(device: &ferrum_types::Device) -> bool {
653    #[cfg(all(any(target_os = "macos", target_os = "ios"), feature = "metal"))]
654    {
655        matches!(device, ferrum_types::Device::Metal)
656    }
657    #[cfg(not(all(any(target_os = "macos", target_os = "ios"), feature = "metal")))]
658    {
659        let _ = device;
660        false
661    }
662}
663
664pub fn serve_profile_runtime_entries_for_arch(
665    is_gguf: bool,
666    is_moe: bool,
667    is_metal: bool,
668    vnext_plan_owns_context_capacity: bool,
669    current: &RuntimeConfigSnapshot,
670    source: RuntimeConfigSource,
671) -> Vec<RuntimeConfigEntry> {
672    if !is_gguf {
673        return Vec::new();
674    }
675
676    let mut entries = Vec::new();
677    if !vnext_plan_owns_context_capacity {
678        let kv_capacity = if is_moe && is_metal {
679            // Metal Qwen3-MoE paged KV is stable for the README c16 path when
680            // total pool blocks stay <= 1024. c16 × 1024 tokens gives exactly
681            // that bound and avoids the repeated-token failure seen at
682            // c16 × 2048.
683            "1024"
684        } else if is_moe {
685            "2048"
686        } else {
687            "512"
688        };
689        push_missing_entry(
690            &mut entries,
691            current,
692            "FERRUM_KV_CAPACITY",
693            kv_capacity,
694            source,
695        );
696    }
697    push_paged_kv_compat_entries(&mut entries, current, "1", source);
698    for (k, v) in [
699        (
700            "FERRUM_PAGED_MAX_SEQS",
701            if is_moe {
702                "16"
703            } else if is_metal {
704                "16"
705            } else {
706                "32"
707            },
708        ),
709        ("FERRUM_MAX_BATCH", "16"),
710    ] {
711        push_missing_entry(&mut entries, current, k, v, source);
712    }
713    if is_moe {
714        for (k, v) in [
715            ("FERRUM_MAX_BATCHED_TOKENS", "2048"),
716            ("FERRUM_MOE_BATCHED", "1"),
717            ("FERRUM_MOE_BATCHED_DECODE", "1"),
718            ("FERRUM_MOE_BATCH_THRESHOLD", "2"),
719        ] {
720            push_missing_entry(&mut entries, current, k, v, source);
721        }
722    }
723    entries
724}
725
726/// Load a model-provided chat template, if the source carries one.
727///
728/// GGUF stores this in `tokenizer.chat_template`; HuggingFace snapshots
729/// commonly store it in `chat_template.jinja`, `chat_template.json`, or
730/// `tokenizer_config.json`. The renderer may still fall back if a template
731/// uses unsupported Jinja features, but callers should always prefer this
732/// metadata over model-name heuristics.
733pub fn load_model_chat_template(snapshot_path: &Path) -> Option<ModelChatTemplate> {
734    let is_gguf = snapshot_path.is_file()
735        && snapshot_path
736            .extension()
737            .map(|e| e.eq_ignore_ascii_case("gguf"))
738            .unwrap_or(false);
739    if is_gguf {
740        let gguf = ferrum_quantization::gguf::GgufFile::open(snapshot_path).ok()?;
741        let template = gguf.metadata_string("tokenizer.chat_template").ok()?;
742        return Some(ModelChatTemplate::new(
743            template.to_string(),
744            format!("{}:tokenizer.chat_template", snapshot_path.display()),
745        ));
746    }
747
748    if !snapshot_path.is_dir() {
749        return None;
750    }
751
752    let jinja_path = snapshot_path.join("chat_template.jinja");
753    if let Ok(template) = std::fs::read_to_string(&jinja_path) {
754        if !template.trim().is_empty() {
755            return Some(ModelChatTemplate::new(
756                template,
757                jinja_path.display().to_string(),
758            ));
759        }
760    }
761
762    let json_path = snapshot_path.join("chat_template.json");
763    if let Some(template) = read_template_json(&json_path) {
764        return Some(template);
765    }
766
767    let tokenizer_config_path = snapshot_path.join("tokenizer_config.json");
768    read_tokenizer_config_template(&tokenizer_config_path)
769}
770
771/// Load the chat template from bytes retained by the immutable tokenizer
772/// source lease. The legacy path-based loader remains for direct GGUF files.
773pub fn load_product_chat_template(
774    sources: &ProductionModelSourceBundle,
775) -> Option<ModelChatTemplate> {
776    if let Some(bytes) = sources.chat_template_jinja() {
777        let template = std::str::from_utf8(bytes).ok()?;
778        if !template.trim().is_empty() {
779            return Some(ModelChatTemplate::new(
780                template.to_owned(),
781                sources
782                    .tokenizer_root()
783                    .join("chat_template.jinja")
784                    .display()
785                    .to_string(),
786            ));
787        }
788    }
789    if let Some(bytes) = sources.chat_template_json() {
790        let origin = sources
791            .tokenizer_root()
792            .join("chat_template.json")
793            .display()
794            .to_string();
795        if let Some(template) = template_json_bytes(bytes, origin) {
796            return Some(template);
797        }
798    }
799    sources.tokenizer_config_json().and_then(|bytes| {
800        tokenizer_config_template_bytes(
801            bytes,
802            sources
803                .tokenizer_root()
804                .join("tokenizer_config.json")
805                .display()
806                .to_string(),
807        )
808    })
809}
810
811fn load_product_chat_template_source(
812    sources: &ProductionModelSourceBundle,
813    source_file: &str,
814) -> Option<ModelChatTemplate> {
815    let origin = sources.tokenizer_root().join(source_file);
816    match source_file {
817        "tokenizer_config.json" => sources
818            .tokenizer_config_json()
819            .and_then(|bytes| tokenizer_config_template_bytes(bytes, origin.display().to_string())),
820        "chat_template.json" => sources
821            .chat_template_json()
822            .and_then(|bytes| template_json_bytes(bytes, origin.display().to_string())),
823        "chat_template.jinja" => sources.chat_template_jinja().and_then(|bytes| {
824            let template = std::str::from_utf8(bytes).ok()?;
825            (!template.trim().is_empty())
826                .then(|| ModelChatTemplate::new(template.to_owned(), origin.display().to_string()))
827        }),
828        _ => None,
829    }
830}
831
832pub fn load_prepared_product_chat_template(
833    prepared: &ferrum_models::vnext::PreparedProductionModel,
834) -> Result<ModelChatTemplate> {
835    let metadata = &prepared.family().metadata().template;
836    let selected = load_product_chat_template_source(prepared.sources(), &metadata.source_file)
837        .ok_or_else(|| {
838            FerrumError::model(format!(
839                "typed product chat template source is unavailable: {}",
840                metadata.source_file
841            ))
842        })?;
843    if selected.template != metadata.template {
844        return Err(FerrumError::model(
845            "typed product chat template bytes differ from the prepared family",
846        ));
847    }
848    Ok(selected)
849}
850
851fn read_template_json(path: &Path) -> Option<ModelChatTemplate> {
852    let bytes = std::fs::read(path).ok()?;
853    template_json_bytes(&bytes, path.display().to_string())
854}
855
856fn template_json_bytes(bytes: &[u8], origin: String) -> Option<ModelChatTemplate> {
857    if bytes.iter().all(u8::is_ascii_whitespace) {
858        return None;
859    }
860    match serde_json::from_slice::<serde_json::Value>(bytes).ok() {
861        Some(serde_json::Value::String(template)) => Some(ModelChatTemplate::new(template, origin)),
862        Some(value) => template_value(&value).map(|template| {
863            let mut t = ModelChatTemplate::new(template, origin);
864            t.bos_token = token_value(&value, "bos_token");
865            t.eos_token = token_value(&value, "eos_token");
866            t
867        }),
868        None => std::str::from_utf8(bytes)
869            .ok()
870            .map(|text| ModelChatTemplate::new(text.to_owned(), origin)),
871    }
872}
873
874fn read_tokenizer_config_template(path: &Path) -> Option<ModelChatTemplate> {
875    let bytes = std::fs::read(path).ok()?;
876    tokenizer_config_template_bytes(&bytes, path.display().to_string())
877}
878
879fn tokenizer_config_template_bytes(bytes: &[u8], origin: String) -> Option<ModelChatTemplate> {
880    let value = serde_json::from_slice::<serde_json::Value>(bytes).ok()?;
881    let template = template_value(&value)?;
882    let mut t = ModelChatTemplate::new(template, origin);
883    t.bos_token = token_value(&value, "bos_token");
884    t.eos_token = token_value(&value, "eos_token");
885    Some(t)
886}
887
888fn template_value(value: &serde_json::Value) -> Option<String> {
889    match value.get("chat_template")? {
890        serde_json::Value::String(s) => Some(s.clone()),
891        serde_json::Value::Array(items) => items
892            .iter()
893            .find(|item| item.get("name").and_then(|v| v.as_str()) == Some("default"))
894            .or_else(|| items.first())
895            .and_then(|item| item.get("template").and_then(|v| v.as_str()))
896            .map(ToString::to_string),
897        serde_json::Value::Object(obj) => obj
898            .get("template")
899            .and_then(|v| v.as_str())
900            .map(ToString::to_string),
901        _ => None,
902    }
903}
904
905fn token_value(value: &serde_json::Value, key: &str) -> Option<String> {
906    match value.get(key)? {
907        serde_json::Value::String(s) => Some(s.clone()),
908        serde_json::Value::Object(obj) => obj
909            .get("content")
910            .and_then(|v| v.as_str())
911            .map(ToString::to_string),
912        _ => None,
913    }
914}
915
916/// Look up `model_id` in the HF cache (`hub/models--owner--repo/snapshots/<rev>`).
917/// Returns the resolved snapshot path + detected format, or `None` if not cached.
918pub fn find_cached_model(cache_dir: &Path, model_id: &str) -> Option<ResolvedModelSource> {
919    let repo_dir = cache_dir
920        .join("hub")
921        .join(format!("models--{}", model_id.replace('/', "--")));
922    let snapshots_dir = repo_dir.join("snapshots");
923
924    // Prefer the revision pointed to by refs/main.
925    let ref_main = repo_dir.join("refs").join("main");
926    if let Ok(rev) = std::fs::read_to_string(&ref_main) {
927        let rev = rev.trim();
928        if !rev.is_empty() {
929            let snapshot = snapshots_dir.join(rev);
930            if snapshot.exists() {
931                let format = detect_format(&snapshot);
932                if format != ModelFormat::Unknown {
933                    return Some(ResolvedModelSource {
934                        original: model_id.to_string(),
935                        local_path: snapshot,
936                        format,
937                        from_cache: true,
938                    });
939                }
940            }
941        }
942    }
943
944    // Fallback: first snapshot directory with valid weights.
945    if let Ok(entries) = std::fs::read_dir(&snapshots_dir) {
946        for entry in entries.flatten() {
947            let path = entry.path();
948            if path.is_dir() {
949                let format = detect_format(&path);
950                if format != ModelFormat::Unknown {
951                    return Some(ResolvedModelSource {
952                        original: model_id.to_string(),
953                        local_path: path,
954                        format,
955                        from_cache: true,
956                    });
957                }
958            }
959        }
960    }
961
962    None
963}
964
965/// Locate one exact GGUF artifact in the Hugging Face cache.
966pub fn find_cached_gguf(cache_dir: &Path, repo: &str, filename: &str) -> Option<PathBuf> {
967    let repo_dir = cache_dir
968        .join("hub")
969        .join(format!("models--{}", repo.replace('/', "--")));
970    let snapshots_dir = repo_dir.join("snapshots");
971
972    let ref_main = repo_dir.join("refs").join("main");
973    if let Ok(revision) = std::fs::read_to_string(&ref_main) {
974        let revision = revision.trim();
975        if !revision.is_empty() {
976            let candidate = snapshots_dir.join(revision).join(filename);
977            if candidate.is_file() {
978                return Some(candidate);
979            }
980        }
981    }
982
983    std::fs::read_dir(&snapshots_dir)
984        .ok()?
985        .flatten()
986        .map(|entry| entry.path().join(filename))
987        .find(|candidate| candidate.is_file())
988}
989
990const PRODUCT_SOURCE_FILES: [&str; 7] = [
991    "config.json",
992    "tokenizer.json",
993    "tokenizer_config.json",
994    "special_tokens_map.json",
995    "chat_template.json",
996    "chat_template.jinja",
997    "generation_config.json",
998];
999
1000fn is_complete_product_metadata_snapshot(path: &Path) -> bool {
1001    path.is_dir() && path.join("config.json").is_file() && path.join("tokenizer.json").is_file()
1002}
1003
1004/// Locate a sidecar-only repository snapshot. Unlike `find_cached_model`, this
1005/// intentionally does not require a weight shard.
1006fn find_cached_product_metadata(cache_dir: &Path, repo: &str) -> Option<PathBuf> {
1007    let repo_dir = cache_dir
1008        .join("hub")
1009        .join(format!("models--{}", repo.replace('/', "--")));
1010    let snapshots_dir = repo_dir.join("snapshots");
1011
1012    if let Ok(revision) = std::fs::read_to_string(repo_dir.join("refs/main")) {
1013        let revision = revision.trim();
1014        if !revision.is_empty() {
1015            let candidate = snapshots_dir.join(revision);
1016            if is_complete_product_metadata_snapshot(&candidate) {
1017                return Some(candidate);
1018            }
1019        }
1020    }
1021
1022    std::fs::read_dir(&snapshots_dir)
1023        .ok()?
1024        .flatten()
1025        .map(|entry| entry.path())
1026        .find(|candidate| is_complete_product_metadata_snapshot(candidate))
1027}
1028
1029fn repository_source(repo: impl Into<String>) -> OriginalModelSource {
1030    OriginalModelSource {
1031        kind: ModelSourceKind::Repository,
1032        location: repo.into(),
1033        requested_revision: None,
1034    }
1035}
1036
1037fn original_product_source(
1038    source: &ModelSource,
1039    resolved_path: &Path,
1040) -> Result<OriginalModelSource> {
1041    match source {
1042        ModelSource::Local(location) => Ok(OriginalModelSource {
1043            kind: if resolved_path.is_file() {
1044                ModelSourceKind::LocalFile
1045            } else {
1046                ModelSourceKind::LocalDirectory
1047            },
1048            location: location.clone(),
1049            requested_revision: None,
1050        }),
1051        ModelSource::HuggingFace {
1052            repo_id, revision, ..
1053        } => Ok(OriginalModelSource {
1054            kind: ModelSourceKind::Repository,
1055            location: repo_id.clone(),
1056            requested_revision: revision.clone(),
1057        }),
1058        ModelSource::Url { .. } | ModelSource::S3 { .. } => Err(FerrumError::unsupported(
1059            "typed product source bundles do not yet resolve URL or S3 sources",
1060        )),
1061    }
1062}
1063
1064fn open_colocated_product_sources(
1065    source: &ResolvedModelSource,
1066    original_source: &ModelSource,
1067) -> Result<Option<Arc<ProductionModelSourceBundle>>> {
1068    let (metadata_root, weights, original_sources) = match source.format {
1069        ModelFormat::SafeTensors if is_complete_product_metadata_snapshot(&source.local_path) => {
1070            let original = original_product_source(original_source, &source.local_path)?;
1071            (
1072                source.local_path.as_path(),
1073                ProductionWeightArtifact::safetensors_directory(&source.local_path),
1074                OriginalModelSources {
1075                    semantic: original.clone(),
1076                    tokenizer: original.clone(),
1077                    weights: original,
1078                },
1079            )
1080        }
1081        ModelFormat::GGUF => {
1082            let metadata_root = source
1083                .local_path
1084                .parent()
1085                .filter(|parent| !parent.as_os_str().is_empty())
1086                .unwrap_or_else(|| Path::new("."));
1087            if !is_complete_product_metadata_snapshot(metadata_root) {
1088                return Ok(None);
1089            }
1090            let metadata_original = OriginalModelSource {
1091                kind: ModelSourceKind::LocalDirectory,
1092                location: metadata_root.display().to_string(),
1093                requested_revision: None,
1094            };
1095            (
1096                metadata_root,
1097                ProductionWeightArtifact::gguf_file(&source.local_path),
1098                OriginalModelSources {
1099                    semantic: metadata_original.clone(),
1100                    tokenizer: metadata_original,
1101                    weights: original_product_source(original_source, &source.local_path)?,
1102                },
1103            )
1104        }
1105        _ => return Ok(None),
1106    };
1107    open_registered_product_sources(metadata_root, metadata_root, weights, original_sources)
1108        .map(Arc::new)
1109        .map(Some)
1110}
1111
1112fn direct_gguf_requires_typed_product_sources(path: &Path) -> bool {
1113    ferrum_quantization::gguf::GgufFile::open(path)
1114        .ok()
1115        .and_then(|gguf| gguf.architecture().ok().map(str::to_owned))
1116        .is_some_and(|architecture| {
1117            ferrum_models::vnext::gguf_architecture_requires_typed_product_sources(&architecture)
1118        })
1119}
1120
1121/// Should the resolver attempt to download from HF if the model isn't
1122/// found locally? `run` / `serve` say yes; `bench` defaults to no
1123/// (caller handles per-bench-flow download policy).
1124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1125pub enum DownloadPolicy {
1126    /// Download from HF if not cached locally.
1127    AutoDownload,
1128    /// Error out if not cached.
1129    NoDownload,
1130}
1131
1132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1133enum ProductSourceComposition {
1134    ResolveColocated,
1135    DeferUntilExplicitSemantic,
1136}
1137
1138/// Resolution outcome — a fully-resolved local source plus a flag the
1139/// caller can use to decide whether to apply GPU autosize.
1140pub struct Resolved {
1141    source: ResolvedModelSource,
1142    requested_model: String,
1143    public_model_id: String,
1144    /// Typed source identity retained past local cache resolution. Product
1145    /// composition uses this instead of reverse-engineering a repository from
1146    /// an opaque snapshot path.
1147    original_source: ModelSource,
1148    /// Immutable role-specific sources for product composition. Direct GGUF
1149    /// files use colocated semantic and tokenizer sources when present;
1150    /// migrated architectures fail closed instead of entering legacy code.
1151    model_sources: Option<Arc<ProductionModelSourceBundle>>,
1152    /// `true` when the resolver also ran the GPU-memory autosizer for
1153    /// this snapshot. Caller can skip a redundant call.
1154    autosized: bool,
1155}
1156
1157/// Atomic handoff from source resolution into a product engine composition.
1158/// Keeping the original source inside the base config prevents entrypoints or
1159/// architecture arms from retaining only the resolved cache path.
1160pub struct ProductEngineInput {
1161    pub source: ResolvedModelSource,
1162    pub requested_model: String,
1163    pub public_model_id: String,
1164    pub engine_config: EngineConfig,
1165    pub model_sources: Option<Arc<ProductionModelSourceBundle>>,
1166    pub autosized: bool,
1167}
1168
1169/// Prepare a migrated typed family exactly once at the product composition
1170/// boundary. Explicit legacy registrations return `None`; unknown metadata is
1171/// rejected by the model registry instead of gaining an implicit fallback.
1172pub fn prepare_registered_product_model(
1173    sources: &Arc<ProductionModelSourceBundle>,
1174) -> Result<Option<Arc<ferrum_models::vnext::PreparedProductionModel>>> {
1175    match ferrum_models::vnext::resolve_registered_model_from_sources(sources)? {
1176        ferrum_models::vnext::ProductionModelRegistration::Registered(registration) => registration
1177            .prepare_from_sources(Arc::clone(sources))
1178            .map(Arc::new)
1179            .map(Some),
1180        ferrum_models::vnext::ProductionModelRegistration::LegacyRegistered { .. } => Ok(None),
1181    }
1182}
1183
1184pub fn prepared_product_source_identity(
1185    prepared: &ferrum_models::vnext::PreparedProductionModel,
1186    requested_model: &str,
1187    resolved_model: &str,
1188    selected_template: Option<&ModelChatTemplate>,
1189) -> Result<ProductModelSourceIdentity> {
1190    let identity = prepared.product_source_identity(requested_model, resolved_model)?;
1191    let selected_template = selected_template.ok_or_else(|| {
1192        FerrumError::model("typed product model has no selected runtime chat template")
1193    })?;
1194    let selected_sha256 = format!(
1195        "{:x}",
1196        Sha256::digest(selected_template.template.as_bytes())
1197    );
1198    if identity.template.content_sha256.as_deref() != Some(selected_sha256.as_str()) {
1199        return Err(FerrumError::model(
1200            "selected runtime chat template differs from the prepared typed family",
1201        ));
1202    }
1203    let selected_file = Path::new(&selected_template.source)
1204        .file_name()
1205        .and_then(|value| value.to_str());
1206    if selected_file != Some(identity.template.source_file.as_str()) {
1207        return Err(FerrumError::model(format!(
1208            "selected runtime chat template source differs from typed identity: {}",
1209            selected_template.source
1210        )));
1211    }
1212    Ok(identity)
1213}
1214
1215impl Resolved {
1216    pub fn local_path(&self) -> &Path {
1217        &self.source.local_path
1218    }
1219
1220    pub fn into_product_engine_input(self) -> ProductEngineInput {
1221        let mut engine_config = EngineConfig::default();
1222        engine_config.model.model_id = ModelId::new(self.public_model_id.clone());
1223        engine_config.model.source = Some(self.original_source);
1224        ProductEngineInput {
1225            source: self.source,
1226            requested_model: self.requested_model,
1227            public_model_id: self.public_model_id,
1228            engine_config,
1229            model_sources: self.model_sources,
1230            autosized: self.autosized,
1231        }
1232    }
1233}
1234
1235/// One-stop model resolution. Caller passes the user's model arg
1236/// (alias / HF id / local dir / `.gguf` path), the HF cache dir, and a
1237/// download policy + autosize profile. Returns a resolved source.
1238///
1239/// Resolution order:
1240///   1. Explicit GGUF alias -> exact cached/downloaded GGUF file.
1241///   2. `*.gguf` file -> direct GGUF source.
1242///   3. Existing local directory with valid weights -> direct source.
1243///   4. HF cache hit -> cached source.
1244///   5. (if `AutoDownload`) HF download -> cached source.
1245///
1246/// On GPU backends the chat-profile autosizer fires once on the resolved
1247/// snapshot before returning, populating `FERRUM_KV_MAX_BLOCKS` etc.
1248/// `apply_autosize=false` skips it (used by `bench` which sets sizing
1249/// from `--max-tokens` etc. directly).
1250pub async fn resolve_model_source(
1251    model: &str,
1252    cache_dir: &Path,
1253    download: DownloadPolicy,
1254    autosize: Option<(AutoSizeProfile, f32)>,
1255) -> Result<Resolved> {
1256    resolve_model_source_internal(
1257        model,
1258        cache_dir,
1259        download,
1260        autosize,
1261        ProductSourceComposition::ResolveColocated,
1262    )
1263    .await
1264}
1265
1266async fn resolve_model_source_internal(
1267    model: &str,
1268    cache_dir: &Path,
1269    download: DownloadPolicy,
1270    autosize: Option<(AutoSizeProfile, f32)>,
1271    source_composition: ProductSourceComposition,
1272) -> Result<Resolved> {
1273    let defer_colocated_product_sources =
1274        source_composition == ProductSourceComposition::DeferUntilExplicitSemantic;
1275    // 1. Curated GGUF alias. Resolve this before the general HF alias table:
1276    // a GGUF alias names one exact file, not a safetensors repository.
1277    if let Some((repo, filename)) = resolve_gguf_alias(model) {
1278        let token = (download == DownloadPolicy::AutoDownload)
1279            .then(|| {
1280                std::env::var("HF_TOKEN")
1281                    .or_else(|_| std::env::var("HUGGING_FACE_HUB_TOKEN"))
1282                    .ok()
1283            })
1284            .flatten();
1285        let (local_path, weights_from_cache) = match find_cached_gguf(cache_dir, &repo, &filename) {
1286            Some(path) => (path, true),
1287            None if download == DownloadPolicy::AutoDownload => {
1288                let downloader =
1289                    ferrum_models::HfDownloader::new(cache_dir.to_path_buf(), token.clone())?;
1290                (
1291                    downloader.download_gguf(&repo, None, &filename).await?,
1292                    false,
1293                )
1294            }
1295            None => {
1296                return Err(FerrumError::model(format!(
1297                    "GGUF alias '{model}' is not cached and DownloadPolicy::NoDownload is set"
1298                )))
1299            }
1300        };
1301        let (model_sources, metadata_from_cache) = if defer_colocated_product_sources {
1302            (None, true)
1303        } else {
1304            let metadata_repo = tokenizer_sibling_repo(&repo).ok_or_else(|| {
1305                FerrumError::model(format!(
1306                    "GGUF repository '{repo}' has no semantic/tokenizer source"
1307                ))
1308            })?;
1309            let (metadata_root, metadata_from_cache) =
1310                match find_cached_product_metadata(cache_dir, &metadata_repo) {
1311                    Some(path) => (path, true),
1312                    None if download == DownloadPolicy::AutoDownload => {
1313                        let downloader =
1314                            ferrum_models::HfDownloader::new(cache_dir.to_path_buf(), token)?;
1315                        let path = downloader
1316                            .download_sidecar_files(&metadata_repo, None, &PRODUCT_SOURCE_FILES)
1317                            .await?;
1318                        if !is_complete_product_metadata_snapshot(&path) {
1319                            return Err(FerrumError::model(format!(
1320                                "semantic/tokenizer source '{metadata_repo}' did not provide config.json and tokenizer.json"
1321                            )));
1322                        }
1323                        (path, false)
1324                    }
1325                    None => {
1326                        return Err(FerrumError::model(format!(
1327                            "semantic/tokenizer source '{metadata_repo}' for GGUF alias '{model}' is not cached and DownloadPolicy::NoDownload is set"
1328                        )))
1329                    }
1330                };
1331            let metadata_original = repository_source(metadata_repo);
1332            let sources = Arc::new(open_registered_product_sources(
1333                &metadata_root,
1334                &metadata_root,
1335                ProductionWeightArtifact::gguf_file(&local_path),
1336                OriginalModelSources {
1337                    semantic: metadata_original.clone(),
1338                    tokenizer: metadata_original,
1339                    weights: repository_source(&repo),
1340                },
1341            )?);
1342            (Some(sources), metadata_from_cache)
1343        };
1344        return Ok(finalize_resolution(
1345            ResolvedModelSource {
1346                original: model.to_string(),
1347                local_path,
1348                format: ModelFormat::GGUF,
1349                from_cache: weights_from_cache && metadata_from_cache,
1350            },
1351            ModelSource::HuggingFace {
1352                repo_id: repo,
1353                revision: None,
1354                cache_dir: Some(cache_dir.display().to_string()),
1355            },
1356            model_sources,
1357            autosize,
1358        ));
1359    }
1360
1361    // 2. GGUF file path.
1362    if looks_like_gguf_path(model) {
1363        let local_path = PathBuf::from(model);
1364        let source = ResolvedModelSource {
1365            original: model.to_string(),
1366            local_path,
1367            format: ModelFormat::GGUF,
1368            from_cache: false,
1369        };
1370        let original_source = ModelSource::Local(model.to_owned());
1371        let model_sources = if defer_colocated_product_sources {
1372            None
1373        } else {
1374            open_colocated_product_sources(&source, &original_source)?
1375        };
1376        if !defer_colocated_product_sources
1377            && model_sources.is_none()
1378            && direct_gguf_requires_typed_product_sources(&source.local_path)
1379        {
1380            return Err(FerrumError::unsupported(format!(
1381                "GGUF architecture in '{}' has migrated to the typed vNext product runtime; use a curated GGUF alias or place config.json and tokenizer.json beside the file",
1382                source.local_path.display()
1383            )));
1384        }
1385        return Ok(finalize_resolution(
1386            source,
1387            original_source,
1388            model_sources,
1389            autosize,
1390        ));
1391    }
1392
1393    // 3. Local directory.
1394    let direct = PathBuf::from(model);
1395    if direct.is_dir() {
1396        let format = detect_format(&direct);
1397        if format != ModelFormat::Unknown {
1398            let source = ResolvedModelSource {
1399                original: model.to_string(),
1400                local_path: direct,
1401                format,
1402                from_cache: false,
1403            };
1404            let original_source = ModelSource::Local(model.to_owned());
1405            let model_sources = if defer_colocated_product_sources {
1406                None
1407            } else {
1408                open_colocated_product_sources(&source, &original_source)?
1409            };
1410            return Ok(finalize_resolution(
1411                source,
1412                original_source,
1413                model_sources,
1414                autosize,
1415            ));
1416        }
1417    }
1418
1419    // 4. HF cache hit.
1420    let model_id = resolve_model_alias(model);
1421    if let Some(source) = find_cached_model(cache_dir, &model_id) {
1422        let original_source = ModelSource::HuggingFace {
1423            repo_id: model_id,
1424            revision: None,
1425            cache_dir: Some(cache_dir.display().to_string()),
1426        };
1427        let model_sources = if defer_colocated_product_sources {
1428            None
1429        } else {
1430            open_colocated_product_sources(&source, &original_source)?
1431        };
1432        return Ok(finalize_resolution(
1433            source,
1434            original_source,
1435            model_sources,
1436            autosize,
1437        ));
1438    }
1439
1440    // 5. HF download.
1441    if download != DownloadPolicy::AutoDownload {
1442        return Err(FerrumError::model(format!(
1443            "model '{}' not found locally and DownloadPolicy::NoDownload set",
1444            model_id
1445        )));
1446    }
1447
1448    let token = std::env::var("HF_TOKEN")
1449        .or_else(|_| std::env::var("HUGGING_FACE_HUB_TOKEN"))
1450        .ok();
1451    let downloader = ferrum_models::HfDownloader::new(cache_dir.to_path_buf(), token)?;
1452    let snapshot_path = downloader.download(&model_id, None).await?;
1453    let format = detect_format(&snapshot_path);
1454    if format == ModelFormat::Unknown {
1455        return Err(FerrumError::model(
1456            "downloaded model has unknown format (no safetensors / pytorch_model.bin)",
1457        ));
1458    }
1459    let source = ResolvedModelSource {
1460        original: model_id.clone(),
1461        local_path: snapshot_path,
1462        format,
1463        from_cache: false,
1464    };
1465    let original_source = ModelSource::HuggingFace {
1466        repo_id: model_id,
1467        revision: None,
1468        cache_dir: Some(cache_dir.display().to_string()),
1469    };
1470    let model_sources = if defer_colocated_product_sources {
1471        None
1472    } else {
1473        open_colocated_product_sources(&source, &original_source)?
1474    };
1475    Ok(finalize_resolution(
1476        source,
1477        original_source,
1478        model_sources,
1479        autosize,
1480    ))
1481}
1482
1483/// Resolve one physical model and then replace only the explicitly selected
1484/// semantic/tokenizer roles. This keeps the positional MODEL as the sole
1485/// weight source while allowing quantized checkpoints to consume canonical
1486/// base-model semantics without a model-name mapping or hidden environment.
1487pub async fn resolve_model_source_with_product_sources(
1488    model: &str,
1489    cache_dir: &Path,
1490    download: DownloadPolicy,
1491    autosize: Option<(AutoSizeProfile, f32)>,
1492    source_args: &ProductSourceArgs,
1493) -> Result<Resolved> {
1494    let mut resolved = resolve_model_source_internal(
1495        model,
1496        cache_dir,
1497        download,
1498        autosize,
1499        if source_args.semantic_source.is_some() {
1500            ProductSourceComposition::DeferUntilExplicitSemantic
1501        } else {
1502            ProductSourceComposition::ResolveColocated
1503        },
1504    )
1505    .await?;
1506    resolved.requested_model = model.to_owned();
1507    apply_explicit_product_sources(resolved, source_args)
1508}
1509
1510fn apply_explicit_product_sources(
1511    mut resolved: Resolved,
1512    source_args: &ProductSourceArgs,
1513) -> Result<Resolved> {
1514    if source_args.semantic_source.is_none() && source_args.tokenizer_source.is_none() {
1515        return Ok(resolved);
1516    }
1517    let existing = resolved.model_sources.as_deref();
1518    let weight_root = match resolved.source.format {
1519        ModelFormat::GGUF => resolved
1520            .source
1521            .local_path
1522            .parent()
1523            .unwrap_or_else(|| Path::new(".")),
1524        _ => resolved.source.local_path.as_path(),
1525    };
1526    let semantic_root = source_args
1527        .semantic_source
1528        .as_deref()
1529        .or_else(|| existing.map(ProductionModelSourceBundle::semantic_root))
1530        .unwrap_or(weight_root);
1531    let tokenizer_root = source_args
1532        .tokenizer_source
1533        .as_deref()
1534        .or_else(|| source_args.semantic_source.as_ref().map(|_| semantic_root))
1535        .or_else(|| existing.map(ProductionModelSourceBundle::tokenizer_root))
1536        .unwrap_or(semantic_root);
1537    let weights = existing
1538        .map(|sources| sources.weights().clone())
1539        .unwrap_or_else(|| match resolved.source.format {
1540            ModelFormat::GGUF => ProductionWeightArtifact::gguf_file(&resolved.source.local_path),
1541            _ => ProductionWeightArtifact::safetensors_directory(&resolved.source.local_path),
1542        });
1543    let explicit_original = |path: &Path| OriginalModelSource {
1544        kind: if path.is_file() {
1545            ModelSourceKind::LocalFile
1546        } else {
1547            ModelSourceKind::LocalDirectory
1548        },
1549        location: path.display().to_string(),
1550        requested_revision: None,
1551    };
1552    let semantic_original = source_args
1553        .semantic_source
1554        .as_deref()
1555        .map(explicit_original)
1556        .or_else(|| existing.map(|sources| sources.original_sources().semantic.clone()))
1557        .unwrap_or_else(|| explicit_original(semantic_root));
1558    let tokenizer_original = source_args
1559        .tokenizer_source
1560        .as_deref()
1561        .map(explicit_original)
1562        .or_else(|| {
1563            source_args
1564                .semantic_source
1565                .as_ref()
1566                .map(|_| semantic_original.clone())
1567        })
1568        .or_else(|| existing.map(|sources| sources.original_sources().tokenizer.clone()))
1569        .unwrap_or_else(|| explicit_original(tokenizer_root));
1570    let weight_original = existing
1571        .map(|sources| sources.original_sources().weights.clone())
1572        .unwrap_or_else(|| {
1573            original_product_source(&resolved.original_source, &resolved.source.local_path)
1574                .unwrap_or_else(|_| explicit_original(weights.path()))
1575        });
1576    resolved.model_sources = Some(Arc::new(open_registered_product_sources(
1577        semantic_root,
1578        tokenizer_root,
1579        weights,
1580        OriginalModelSources {
1581            semantic: semantic_original,
1582            tokenizer: tokenizer_original,
1583            weights: weight_original,
1584        },
1585    )?));
1586    Ok(resolved)
1587}
1588
1589fn finalize_resolution(
1590    source: ResolvedModelSource,
1591    original_source: ModelSource,
1592    model_sources: Option<Arc<ProductionModelSourceBundle>>,
1593    autosize: Option<(AutoSizeProfile, f32)>,
1594) -> Resolved {
1595    let autosized = if let Some((profile, gpu_util)) = autosize {
1596        apply_auto_size_with_profile(&source.local_path, gpu_util, profile);
1597        if profile == AutoSizeProfile::Chat {
1598            apply_chat_profile_env(&source.local_path);
1599        }
1600        true
1601    } else {
1602        false
1603    };
1604    let requested_model = source.original.clone();
1605    let public_model_id = public_model_id(&source);
1606    Resolved {
1607        source,
1608        requested_model,
1609        public_model_id,
1610        original_source,
1611        model_sources,
1612        autosized,
1613    }
1614}
1615
1616#[cfg(test)]
1617mod tests {
1618    use super::*;
1619    use std::time::{SystemTime, UNIX_EPOCH};
1620
1621    fn temp_model_dir(name: &str, config_json: &str) -> PathBuf {
1622        let nonce = SystemTime::now()
1623            .duration_since(UNIX_EPOCH)
1624            .unwrap()
1625            .as_nanos();
1626        let dir = std::env::temp_dir().join(format!(
1627            "ferrum-source-resolver-{name}-{}-{nonce}",
1628            std::process::id()
1629        ));
1630        std::fs::create_dir_all(&dir).unwrap();
1631        std::fs::write(dir.join("config.json"), config_json).unwrap();
1632        std::fs::write(dir.join("tokenizer.json"), br#"{"version":"1.0"}"#).unwrap();
1633        dir
1634    }
1635
1636    fn qwen35_semantic_config(moe: bool) -> String {
1637        let mut text = serde_json::json!({
1638            "model_type": if moe { "qwen3_5_moe_text" } else { "qwen3_5_text" },
1639            "hidden_size": 16,
1640            "num_hidden_layers": 2,
1641            "layer_types": ["linear_attention", "full_attention"],
1642            "linear_num_key_heads": 1,
1643            "linear_num_value_heads": 1,
1644            "linear_key_head_dim": 4,
1645            "linear_value_head_dim": 4,
1646            "linear_conv_kernel_dim": 2,
1647            "mamba_ssm_dtype": "float32",
1648            "head_dim": 4,
1649            "num_attention_heads": 1,
1650            "num_key_value_heads": 1,
1651            "max_position_embeddings": 128,
1652            "vocab_size": 32,
1653            "rms_norm_eps": 0.000001,
1654            "rope_parameters": {
1655                "rope_theta": 10000.0,
1656                "partial_rotary_factor": 1.0,
1657                "mrope_interleaved": false
1658            }
1659        });
1660        let text = text.as_object_mut().unwrap();
1661        if moe {
1662            text.insert("num_experts".to_owned(), serde_json::json!(4));
1663            text.insert("num_experts_per_tok".to_owned(), serde_json::json!(2));
1664            text.insert("moe_intermediate_size".to_owned(), serde_json::json!(8));
1665            text.insert(
1666                "shared_expert_intermediate_size".to_owned(),
1667                serde_json::json!(8),
1668            );
1669        } else {
1670            text.insert("intermediate_size".to_owned(), serde_json::json!(32));
1671        }
1672        serde_json::json!({
1673            "architectures": [if moe {
1674                "Qwen3_5MoeForConditionalGeneration"
1675            } else {
1676                "Qwen3_5ForConditionalGeneration"
1677            }],
1678            "model_type": if moe { "qwen3_5_moe" } else { "qwen3_5" },
1679            "text_config": text,
1680            "tie_word_embeddings": false
1681        })
1682        .to_string()
1683    }
1684
1685    fn value(entries: &[RuntimeConfigEntry], key: &str) -> Option<String> {
1686        entries
1687            .iter()
1688            .find(|entry| entry.key == key)
1689            .map(|entry| entry.effective_value.clone())
1690    }
1691
1692    #[tokio::test]
1693    async fn explicit_semantic_preflight_rejects_before_weight_binding() {
1694        let weights = temp_model_dir(
1695            "preflight-weights",
1696            r#"{"architectures":["Qwen3_5MoeForConditionalGeneration"]}"#,
1697        );
1698        std::fs::write(weights.join("model.safetensors"), []).unwrap();
1699        let semantic = temp_model_dir(
1700            "preflight-semantic",
1701            r#"{
1702                "architectures":["Qwen3_5MoeForConditionalGeneration"],
1703                "model_type":"qwen3_5_moe",
1704                "text_config":{"model_type":"unsupported_nested_layout"}
1705            }"#,
1706        );
1707        let args = ProductSourceArgs {
1708            semantic_source: Some(semantic.clone()),
1709            tokenizer_source: None,
1710        };
1711
1712        let error = resolve_model_source_with_product_sources(
1713            weights.to_str().unwrap(),
1714            &weights.join("unused-cache"),
1715            DownloadPolicy::NoDownload,
1716            None,
1717            &args,
1718        )
1719        .await
1720        .err()
1721        .expect("invalid semantic layout must fail before weight binding")
1722        .to_string();
1723
1724        assert!(
1725            error.contains("unsupported Qwen3.5 text model_type"),
1726            "{error}"
1727        );
1728        assert!(
1729            !error.contains("source manifest file is missing or empty"),
1730            "{error}"
1731        );
1732        let _ = std::fs::remove_dir_all(weights);
1733        let _ = std::fs::remove_dir_all(semantic);
1734    }
1735
1736    #[test]
1737    fn hf_and_gguf_aliases_are_disjoint() {
1738        for entry in GGUF_ALIASES {
1739            for alias in entry.aliases {
1740                assert_eq!(
1741                    resolve_model_alias(alias),
1742                    *alias,
1743                    "alias '{alias}' resolves to both an HF repository and a GGUF file"
1744                );
1745            }
1746        }
1747        assert_eq!(resolve_model_alias("qwen3:1.7b"), "Qwen/Qwen3-1.7B");
1748        assert_eq!(resolve_model_alias("qwen3.5:4b"), "Qwen/Qwen3.5-4B");
1749        assert!(resolve_gguf_alias("qwen3:1.7b").is_none());
1750        assert!(resolve_gguf_alias("qwen3:1.7b-gguf").is_some());
1751        assert_eq!(
1752            resolve_gguf_alias("qwen3.5:4b-q4_k_m"),
1753            Some((
1754                "unsloth/Qwen3.5-4B-GGUF".to_string(),
1755                "Qwen3.5-4B-Q4_K_M.gguf".to_string()
1756            ))
1757        );
1758        assert_eq!(
1759            resolve_gguf_alias("qwen3.5:35b-a3b-q4_k_s"),
1760            Some((
1761                "unsloth/Qwen3.5-35B-A3B-GGUF".to_string(),
1762                "Qwen3.5-35B-A3B-Q4_K_S.gguf".to_string()
1763            ))
1764        );
1765    }
1766
1767    #[tokio::test]
1768    async fn resolves_local_model_directory_with_stable_product_id() {
1769        let config = qwen35_semantic_config(false);
1770        let dir = temp_model_dir("local-product-id", &config);
1771        std::fs::write(dir.join("model.safetensors"), b"fixture-weights").unwrap();
1772
1773        let resolved = resolve_model_source(
1774            dir.to_str().unwrap(),
1775            &dir.join("unused-cache"),
1776            DownloadPolicy::NoDownload,
1777            None,
1778        )
1779        .await
1780        .unwrap();
1781        let product = resolved.into_product_engine_input();
1782
1783        assert_eq!(product.source.local_path, dir);
1784        assert_eq!(product.source.format, ModelFormat::SafeTensors);
1785        assert!(!product.source.from_cache);
1786        assert!(!product.autosized);
1787        let sources = product.model_sources.as_ref().unwrap();
1788        assert_eq!(sources.semantic_root(), dir.canonicalize().unwrap());
1789        assert_eq!(sources.tokenizer_root(), dir.canonicalize().unwrap());
1790        assert!(matches!(
1791            product.engine_config.model.source.as_ref().unwrap(),
1792            ModelSource::Local(path) if path == dir.to_str().unwrap()
1793        ));
1794        assert_eq!(
1795            product.engine_config.model.model_id.as_str(),
1796            dir.file_name().unwrap().to_string_lossy()
1797        );
1798        let _ = std::fs::remove_dir_all(dir);
1799    }
1800
1801    #[tokio::test]
1802    async fn explicit_semantic_source_replaces_metadata_roles_not_weights() {
1803        let weights = temp_model_dir(
1804            "explicit-role-weights",
1805            r#"{"architectures":["Qwen3_5MoeForConditionalGeneration"],"quantization_config":{"quant_method":"gptq"}}"#,
1806        );
1807        std::fs::write(weights.join("model.safetensors"), b"fixture-weights").unwrap();
1808        let semantic_config = qwen35_semantic_config(true);
1809        let semantic = temp_model_dir("explicit-role-semantic", &semantic_config);
1810        std::fs::write(
1811            semantic.join("tokenizer_config.json"),
1812            br#"{"chat_template":"fixture"}"#,
1813        )
1814        .unwrap();
1815        let args = ProductSourceArgs {
1816            semantic_source: Some(semantic.clone()),
1817            tokenizer_source: None,
1818        };
1819
1820        let resolved = resolve_model_source_with_product_sources(
1821            weights.to_str().unwrap(),
1822            &weights.join("unused-cache"),
1823            DownloadPolicy::NoDownload,
1824            None,
1825            &args,
1826        )
1827        .await
1828        .unwrap();
1829        let product = resolved.into_product_engine_input();
1830        let sources = product.model_sources.unwrap();
1831        assert_eq!(product.requested_model, weights.display().to_string());
1832        assert_eq!(sources.semantic_root(), semantic.canonicalize().unwrap());
1833        assert_eq!(sources.tokenizer_root(), semantic.canonicalize().unwrap());
1834        assert_eq!(sources.weights().path(), weights.canonicalize().unwrap());
1835        assert!(sources
1836            .fingerprint(
1837                ferrum_interfaces::vnext::ModelArtifactSourceRole::Weights,
1838                "config.json",
1839            )
1840            .is_some());
1841        let _ = std::fs::remove_dir_all(weights);
1842        let _ = std::fs::remove_dir_all(semantic);
1843    }
1844
1845    #[test]
1846    fn direct_huggingface_snapshot_uses_stable_repository_public_id() {
1847        let revision = "a".repeat(40);
1848        let source = ResolvedModelSource {
1849            original: "/cache/models--Qwen--Qwen3.5-35B-A3B-GPTQ-Int4/snapshots/local".to_owned(),
1850            local_path: PathBuf::from(format!(
1851                "/cache/models--Qwen--Qwen3.5-35B-A3B-GPTQ-Int4/snapshots/{revision}"
1852            )),
1853            format: ModelFormat::SafeTensors,
1854            from_cache: false,
1855        };
1856
1857        assert_eq!(public_model_id(&source), "Qwen/Qwen3.5-35B-A3B-GPTQ-Int4");
1858
1859        let gguf_source = ResolvedModelSource {
1860            original: "/cache/models--unsloth--Qwen3.5-35B-A3B-GGUF/snapshots/local/model.gguf"
1861                .to_owned(),
1862            local_path: PathBuf::from(format!(
1863                "/cache/models--unsloth--Qwen3.5-35B-A3B-GGUF/snapshots/{revision}/model.gguf"
1864            )),
1865            format: ModelFormat::GGUF,
1866            from_cache: false,
1867        };
1868        assert_eq!(
1869            public_model_id(&gguf_source),
1870            "unsloth/Qwen3.5-35B-A3B-GGUF"
1871        );
1872    }
1873
1874    #[tokio::test]
1875    async fn resolves_direct_gguf_package_with_file_stem_product_id() {
1876        let config = qwen35_semantic_config(false);
1877        let dir = temp_model_dir("direct-gguf-package", &config);
1878        let gguf = dir.join("Qwen3.5-4B-Instruct-Q4_K_M.gguf");
1879        std::fs::write(&gguf, b"fixture-gguf").unwrap();
1880
1881        let resolved = resolve_model_source(
1882            gguf.to_str().unwrap(),
1883            &dir.join("unused-cache"),
1884            DownloadPolicy::NoDownload,
1885            None,
1886        )
1887        .await
1888        .unwrap();
1889        let product = resolved.into_product_engine_input();
1890
1891        assert_eq!(product.source.local_path, gguf);
1892        assert_eq!(product.source.format, ModelFormat::GGUF);
1893        let sources = product.model_sources.as_ref().unwrap();
1894        assert_eq!(sources.semantic_root(), dir.canonicalize().unwrap());
1895        assert_eq!(sources.tokenizer_root(), dir.canonicalize().unwrap());
1896        assert_eq!(sources.weights().path(), gguf.canonicalize().unwrap());
1897        assert!(matches!(
1898            ferrum_models::vnext::resolve_registered_model_from_sources(sources).unwrap(),
1899            ferrum_models::vnext::ProductionModelRegistration::Registered(_)
1900        ));
1901        assert!(matches!(
1902            product.engine_config.model.source.as_ref().unwrap(),
1903            ModelSource::Local(path) if path == gguf.to_str().unwrap()
1904        ));
1905        assert_eq!(
1906            product.engine_config.model.model_id.as_str(),
1907            "Qwen3.5-4B-Instruct-Q4_K_M"
1908        );
1909        let _ = std::fs::remove_dir_all(dir);
1910    }
1911
1912    #[tokio::test]
1913    async fn unresolved_direct_gguf_keeps_legacy_compatibility_for_unmigrated_architectures() {
1914        let nonce = SystemTime::now()
1915            .duration_since(UNIX_EPOCH)
1916            .unwrap()
1917            .as_nanos();
1918        let dir = std::env::temp_dir().join(format!(
1919            "ferrum-source-resolver-untyped-gguf-{}-{nonce}",
1920            std::process::id()
1921        ));
1922        std::fs::create_dir_all(&dir).unwrap();
1923        let gguf = dir.join("legacy-model.gguf");
1924        std::fs::write(&gguf, []).unwrap();
1925
1926        let resolved = resolve_model_source(
1927            gguf.to_str().unwrap(),
1928            &dir.join("unused-cache"),
1929            DownloadPolicy::NoDownload,
1930            None,
1931        )
1932        .await
1933        .unwrap();
1934        let product = resolved.into_product_engine_input();
1935
1936        assert!(product.model_sources.is_none());
1937        assert_eq!(product.source.local_path, gguf);
1938        let _ = std::fs::remove_dir_all(dir);
1939    }
1940
1941    #[tokio::test]
1942    async fn resolves_cached_gguf_alias_without_entrypoint_short_circuit() {
1943        let cache = temp_model_dir("cached-gguf-alias", r#"{}"#);
1944        let (repo, filename) = resolve_gguf_alias("qwen3:4b-q4_k_m").unwrap();
1945        let repo_dir = cache
1946            .join("hub")
1947            .join(format!("models--{}", repo.replace('/', "--")));
1948        let revision = "fixture-revision";
1949        let snapshot = repo_dir.join("snapshots").join(revision);
1950        std::fs::create_dir_all(&snapshot).unwrap();
1951        std::fs::create_dir_all(repo_dir.join("refs")).unwrap();
1952        std::fs::write(repo_dir.join("refs/main"), revision).unwrap();
1953        let gguf = snapshot.join(&filename);
1954        std::fs::write(&gguf, b"fixture-gguf").unwrap();
1955
1956        let metadata_repo = tokenizer_sibling_repo(&repo).unwrap();
1957        let metadata_repo_dir = cache
1958            .join("hub")
1959            .join(format!("models--{}", metadata_repo.replace('/', "--")));
1960        let metadata_revision = "metadata-fixture-revision";
1961        let metadata_snapshot = metadata_repo_dir.join("snapshots").join(metadata_revision);
1962        std::fs::create_dir_all(&metadata_snapshot).unwrap();
1963        std::fs::create_dir_all(metadata_repo_dir.join("refs")).unwrap();
1964        std::fs::write(metadata_repo_dir.join("refs/main"), metadata_revision).unwrap();
1965        std::fs::write(
1966            metadata_snapshot.join("config.json"),
1967            br#"{"architectures":["Qwen3ForCausalLM"]}"#,
1968        )
1969        .unwrap();
1970        std::fs::write(
1971            metadata_snapshot.join("tokenizer.json"),
1972            br#"{"version":"1.0"}"#,
1973        )
1974        .unwrap();
1975        std::fs::write(
1976            metadata_snapshot.join("tokenizer_config.json"),
1977            br#"{"chat_template":"fixture-template"}"#,
1978        )
1979        .unwrap();
1980
1981        let resolved =
1982            resolve_model_source("qwen3:4b-q4_k_m", &cache, DownloadPolicy::NoDownload, None)
1983                .await
1984                .unwrap();
1985        let product = resolved.into_product_engine_input();
1986
1987        assert_eq!(product.source.local_path, gguf);
1988        assert_eq!(product.source.format, ModelFormat::GGUF);
1989        assert!(product.source.from_cache);
1990        let sources = product.model_sources.as_ref().unwrap();
1991        assert_eq!(
1992            sources.semantic_root(),
1993            metadata_snapshot.canonicalize().unwrap()
1994        );
1995        assert_eq!(
1996            sources.tokenizer_root(),
1997            metadata_snapshot.canonicalize().unwrap()
1998        );
1999        assert_eq!(sources.weights().path(), gguf.canonicalize().unwrap());
2000        assert_eq!(sources.original_sources().semantic.location, metadata_repo);
2001        assert_eq!(sources.original_sources().weights.location, repo);
2002        assert!(!snapshot.join("tokenizer.json").exists());
2003        assert!(matches!(
2004            product.engine_config.model.source.as_ref().unwrap(),
2005            ModelSource::HuggingFace { repo_id, revision: None, cache_dir: Some(root) }
2006                if repo_id == &repo && root == &cache.display().to_string()
2007        ));
2008        assert_eq!(
2009            product.engine_config.model.model_id.as_str(),
2010            Path::new(&filename).file_stem().unwrap().to_string_lossy()
2011        );
2012        let _ = std::fs::remove_dir_all(cache);
2013    }
2014
2015    #[test]
2016    fn serve_profile_defaults_metal_gguf_moe_without_user_env() {
2017        let entries = serve_profile_runtime_entries_for_arch(
2018            true,
2019            true,
2020            true,
2021            false,
2022            &RuntimeConfigSnapshot::default(),
2023            RuntimeConfigSource::Default,
2024        );
2025
2026        assert_eq!(
2027            value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2028            Some("1024")
2029        );
2030        assert_eq!(
2031            value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2032            Some("16")
2033        );
2034        assert_eq!(
2035            value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2036            Some("1")
2037        );
2038        assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2039        assert_eq!(value(&entries, "FERRUM_MAX_BATCH").as_deref(), Some("16"));
2040        assert_eq!(value(&entries, "FERRUM_MOE_BATCHED").as_deref(), Some("1"));
2041        assert_eq!(
2042            value(&entries, "FERRUM_MOE_BATCHED_DECODE").as_deref(),
2043            Some("1")
2044        );
2045    }
2046
2047    #[test]
2048    fn serve_profile_keeps_multi_seq_default_for_non_metal_moe() {
2049        let entries = serve_profile_runtime_entries_for_arch(
2050            true,
2051            true,
2052            false,
2053            false,
2054            &RuntimeConfigSnapshot::default(),
2055            RuntimeConfigSource::Default,
2056        );
2057
2058        assert_eq!(
2059            value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2060            Some("2048")
2061        );
2062        assert_eq!(
2063            value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2064            Some("16")
2065        );
2066        assert_eq!(
2067            value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2068            Some("1")
2069        );
2070        assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2071        assert_eq!(value(&entries, "FERRUM_MAX_BATCH").as_deref(), Some("16"));
2072        assert_eq!(value(&entries, "FERRUM_MOE_BATCHED").as_deref(), Some("1"));
2073        assert_eq!(
2074            value(&entries, "FERRUM_MOE_BATCHED_DECODE").as_deref(),
2075            Some("1")
2076        );
2077    }
2078
2079    #[test]
2080    fn serve_profile_defaults_gguf_dense_without_moe_env() {
2081        let entries = serve_profile_runtime_entries_for_arch(
2082            true,
2083            false,
2084            true,
2085            false,
2086            &RuntimeConfigSnapshot::default(),
2087            RuntimeConfigSource::Default,
2088        );
2089
2090        assert_eq!(
2091            value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2092            Some("512")
2093        );
2094        assert_eq!(
2095            value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2096            Some("16")
2097        );
2098        assert_eq!(
2099            value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2100            Some("1")
2101        );
2102        assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2103        assert_eq!(value(&entries, "FERRUM_MOE_BATCHED"), None);
2104    }
2105
2106    #[test]
2107    fn serve_profile_leaves_context_capacity_to_vnext_plan() {
2108        let entries = serve_profile_runtime_entries_for_arch(
2109            true,
2110            false,
2111            true,
2112            true,
2113            &RuntimeConfigSnapshot::default(),
2114            RuntimeConfigSource::Default,
2115        );
2116
2117        assert_eq!(value(&entries, "FERRUM_KV_CAPACITY"), None);
2118        assert_eq!(
2119            value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2120            Some("16")
2121        );
2122        assert_eq!(
2123            value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2124            Some("1")
2125        );
2126        assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2127        assert_eq!(value(&entries, "FERRUM_MAX_BATCH").as_deref(), Some("16"));
2128    }
2129
2130    #[test]
2131    fn serve_profile_respects_explicit_user_env() {
2132        let current = RuntimeConfigSnapshot::from_entries(vec![RuntimeConfigEntry::new(
2133            "FERRUM_KV_CAPACITY",
2134            "4096",
2135            RuntimeConfigSource::Default,
2136        )]);
2137        let entries = serve_profile_runtime_entries_for_arch(
2138            true,
2139            true,
2140            true,
2141            false,
2142            &current,
2143            RuntimeConfigSource::Default,
2144        );
2145
2146        assert_eq!(value(&entries, "FERRUM_KV_CAPACITY"), None);
2147        assert_eq!(
2148            value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2149            Some("16")
2150        );
2151    }
2152
2153    #[test]
2154    fn chat_profile_defaults_dense_safetensors_as_typed_entries() {
2155        let dir = temp_model_dir(
2156            "dense",
2157            r#"{"architectures":["Qwen3ForCausalLM"],"model_type":"qwen3"}"#,
2158        );
2159        let entries = chat_profile_runtime_entries(
2160            &dir,
2161            &RuntimeConfigSnapshot::default(),
2162            RuntimeConfigSource::Default,
2163        );
2164
2165        assert_eq!(
2166            value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2167            Some("8192")
2168        );
2169        assert_eq!(
2170            value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2171            Some("1")
2172        );
2173        assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2174        assert_eq!(
2175            value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2176            Some("2")
2177        );
2178        assert_eq!(value(&entries, "FERRUM_MAX_BATCH").as_deref(), Some("1"));
2179        assert_eq!(value(&entries, "FERRUM_MOE_BATCHED"), None);
2180        let _ = std::fs::remove_dir_all(dir);
2181    }
2182
2183    #[test]
2184    fn chat_profile_recognizes_qwen35_dense_without_qwen3_fallback() {
2185        let dir = temp_model_dir(
2186            "qwen35_dense",
2187            r#"{"architectures":["Qwen3_5ForConditionalGeneration"],"model_type":"qwen3_5"}"#,
2188        );
2189        assert_eq!(detect_model_family(&dir).as_deref(), Some("qwen3_5"));
2190        assert!(!detect_moe_arch(&dir));
2191
2192        let entries = chat_profile_runtime_entries(
2193            &dir,
2194            &RuntimeConfigSnapshot::default(),
2195            RuntimeConfigSource::Default,
2196        );
2197
2198        assert_eq!(
2199            value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2200            Some("1")
2201        );
2202        assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2203        assert_eq!(value(&entries, "FERRUM_MOE_BATCHED"), None);
2204        let _ = std::fs::remove_dir_all(dir);
2205    }
2206
2207    #[test]
2208    fn chat_profile_disables_metal_paged_kv_for_llama_safetensors() {
2209        let dir = temp_model_dir(
2210            "llama",
2211            r#"{"architectures":["LlamaForCausalLM"],"model_type":"llama"}"#,
2212        );
2213        let entries = chat_profile_runtime_entries(
2214            &dir,
2215            &RuntimeConfigSnapshot::default(),
2216            RuntimeConfigSource::Default,
2217        );
2218
2219        assert_eq!(
2220            value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2221            Some("0")
2222        );
2223        assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("0"));
2224        let _ = std::fs::remove_dir_all(dir);
2225    }
2226
2227    #[test]
2228    fn chat_profile_disables_metal_paged_kv_for_qwen2_safetensors() {
2229        let dir = temp_model_dir(
2230            "qwen2",
2231            r#"{"architectures":["Qwen2ForCausalLM"],"model_type":"qwen2"}"#,
2232        );
2233        let entries = chat_profile_runtime_entries(
2234            &dir,
2235            &RuntimeConfigSnapshot::default(),
2236            RuntimeConfigSource::Default,
2237        );
2238
2239        assert_eq!(
2240            value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2241            Some("0")
2242        );
2243        assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("0"));
2244        let _ = std::fs::remove_dir_all(dir);
2245    }
2246
2247    #[test]
2248    fn chat_profile_defaults_moe_safetensors_as_typed_entries() {
2249        let dir = temp_model_dir(
2250            "moe",
2251            r#"{"architectures":["Qwen3MoeForCausalLM"],"model_type":"qwen3_moe"}"#,
2252        );
2253        let entries = chat_profile_runtime_entries(
2254            &dir,
2255            &RuntimeConfigSnapshot::default(),
2256            RuntimeConfigSource::Default,
2257        );
2258
2259        assert_eq!(
2260            value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2261            Some("4096")
2262        );
2263        assert_eq!(
2264            value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2265            Some("1")
2266        );
2267        assert_eq!(
2268            value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2269            Some("1")
2270        );
2271        assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("1"));
2272        assert_eq!(value(&entries, "FERRUM_MOE_BATCHED").as_deref(), Some("0"));
2273        assert_eq!(
2274            value(&entries, "FERRUM_MOE_BATCHED_DECODE").as_deref(),
2275            Some("0")
2276        );
2277        assert_eq!(
2278            value(&entries, "FERRUM_MOE_BATCH_THRESHOLD").as_deref(),
2279            Some("2")
2280        );
2281        let _ = std::fs::remove_dir_all(dir);
2282    }
2283
2284    #[test]
2285    fn chat_profile_recognizes_qwen35_moe_as_distinct_moe_family() {
2286        let dir = temp_model_dir(
2287            "qwen35_moe",
2288            r#"{"architectures":["Qwen3_5MoeForConditionalGeneration"],"model_type":"qwen3_5_moe"}"#,
2289        );
2290        assert_eq!(detect_model_family(&dir).as_deref(), Some("qwen3_5_moe"));
2291        assert!(detect_moe_arch(&dir));
2292
2293        let entries = chat_profile_runtime_entries(
2294            &dir,
2295            &RuntimeConfigSnapshot::default(),
2296            RuntimeConfigSource::Default,
2297        );
2298
2299        assert_eq!(
2300            value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2301            Some("4096")
2302        );
2303        assert_eq!(value(&entries, "FERRUM_MOE_BATCHED").as_deref(), Some("0"));
2304        let _ = std::fs::remove_dir_all(dir);
2305    }
2306
2307    #[test]
2308    fn chat_profile_defaults_moe_gguf_as_typed_entries() {
2309        let entries = chat_profile_runtime_entries_for_arch(
2310            true,
2311            true,
2312            Some("qwen3_moe"),
2313            &RuntimeConfigSnapshot::default(),
2314            RuntimeConfigSource::Default,
2315        );
2316
2317        assert_eq!(
2318            value(&entries, "FERRUM_KV_CAPACITY").as_deref(),
2319            Some("4096")
2320        );
2321        assert_eq!(
2322            value(&entries, "FERRUM_PAGED_MAX_SEQS").as_deref(),
2323            Some("1")
2324        );
2325        assert_eq!(
2326            value(&entries, "FERRUM_METAL_PAGED_KV").as_deref(),
2327            Some("0")
2328        );
2329        assert_eq!(value(&entries, "FERRUM_PAGED_KV").as_deref(), Some("0"));
2330        assert_eq!(value(&entries, "FERRUM_MOE_BATCHED").as_deref(), Some("0"));
2331        assert_eq!(
2332            value(&entries, "FERRUM_MOE_BATCHED_DECODE").as_deref(),
2333            Some("0")
2334        );
2335    }
2336
2337    #[test]
2338    fn chat_profile_defaults_preserve_existing_snapshot_values() {
2339        let dir = temp_model_dir(
2340            "override",
2341            r#"{"architectures":["Qwen3MoeForCausalLM"],"model_type":"qwen3_moe"}"#,
2342        );
2343        let current = RuntimeConfigSnapshot::from_entries([
2344            RuntimeConfigEntry::new("FERRUM_KV_CAPACITY", "1234", RuntimeConfigSource::Env),
2345            RuntimeConfigEntry::new("FERRUM_MOE_BATCH_THRESHOLD", "7", RuntimeConfigSource::Env),
2346        ]);
2347        let entries = chat_profile_runtime_entries(&dir, &current, RuntimeConfigSource::Default);
2348
2349        assert_eq!(value(&entries, "FERRUM_KV_CAPACITY"), None);
2350        assert_eq!(value(&entries, "FERRUM_MOE_BATCH_THRESHOLD"), None);
2351        assert_eq!(value(&entries, "FERRUM_MOE_BATCHED").as_deref(), Some("0"));
2352        let _ = std::fs::remove_dir_all(dir);
2353    }
2354
2355    #[test]
2356    fn load_model_chat_template_reads_tokenizer_config() {
2357        let dir = temp_model_dir(
2358            "template",
2359            r#"{"architectures":["Qwen3ForCausalLM"],"model_type":"qwen3"}"#,
2360        );
2361        std::fs::write(
2362            dir.join("tokenizer_config.json"),
2363            r#"{"chat_template":"{{ messages[0].content }}","bos_token":"<s>","eos_token":"</s>"}"#,
2364        )
2365        .unwrap();
2366
2367        let template = load_model_chat_template(&dir).unwrap();
2368        assert_eq!(template.template, "{{ messages[0].content }}");
2369        assert_eq!(template.bos_token.as_deref(), Some("<s>"));
2370        assert_eq!(template.eos_token.as_deref(), Some("</s>"));
2371        let _ = std::fs::remove_dir_all(dir);
2372    }
2373
2374    #[test]
2375    fn product_chat_template_uses_immutable_source_bytes() {
2376        let dir = temp_model_dir(
2377            "immutable-template",
2378            r#"{"architectures":["Qwen3ForCausalLM"],"model_type":"qwen3"}"#,
2379        );
2380        std::fs::write(dir.join("model.safetensors"), b"fixture-weights").unwrap();
2381        std::fs::write(
2382            dir.join("tokenizer_config.json"),
2383            r#"{"chat_template":"original-template","eos_token":"</s>"}"#,
2384        )
2385        .unwrap();
2386        let bundle = ProductionModelSourceBundle::open_colocated_safetensors(&dir).unwrap();
2387
2388        std::fs::write(
2389            dir.join("tokenizer_config.json"),
2390            r#"{"chat_template":"mutated-template"}"#,
2391        )
2392        .unwrap();
2393
2394        let template = load_product_chat_template(&bundle).unwrap();
2395        assert_eq!(template.template, "original-template");
2396        assert_eq!(template.eos_token.as_deref(), Some("</s>"));
2397        let _ = std::fs::remove_dir_all(dir);
2398    }
2399
2400    #[test]
2401    fn typed_template_source_ignores_unselected_duplicate() {
2402        let dir = temp_model_dir(
2403            "typed-template-source",
2404            r#"{"architectures":["Qwen3ForCausalLM"],"model_type":"qwen3"}"#,
2405        );
2406        std::fs::write(dir.join("model.safetensors"), b"fixture-weights").unwrap();
2407        std::fs::write(
2408            dir.join("tokenizer_config.json"),
2409            r#"{"chat_template":"typed-template","eos_token":"</s>"}"#,
2410        )
2411        .unwrap();
2412        std::fs::write(dir.join("chat_template.jinja"), "unselected-template").unwrap();
2413        let bundle = ProductionModelSourceBundle::open_colocated_safetensors(&dir).unwrap();
2414
2415        let template = load_product_chat_template_source(&bundle, "tokenizer_config.json").unwrap();
2416        assert_eq!(template.template, "typed-template");
2417        assert_eq!(template.eos_token.as_deref(), Some("</s>"));
2418        assert!(template.source.ends_with("tokenizer_config.json"));
2419        let _ = std::fs::remove_dir_all(dir);
2420    }
2421}