Skip to main content

rlx_models_core/
weight_loader.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Pluggable weight loader trait (plan #56).
17//!
18//! Borrowed from MAX's `max/python/max/graph/weights/` layout:
19//! `load_safetensors.py`, `load_gguf.py`, plus a `load.py` dispatcher
20//! that detects format from the file extension.
21//!
22//! Today the only impl is safetensors (via the existing
23//! [`WeightMap::from_file`]). Adding a new format = one new struct
24//! that implements [`WeightLoader`] + an extension match in
25//! [`load_from_path`]. The model graph builders take any
26//! `&mut dyn WeightLoader` so they don't care which format the
27//! weights came from.
28
29use anyhow::{Context, Result, anyhow};
30use rlx_gguf::MetaValue;
31use std::collections::{HashMap, HashSet};
32use std::path::Path;
33use std::sync::Arc;
34
35/// Walk the GGUF metadata for `{arch}.block_count -
36/// {arch}.nextn_predict_layers`. Returns `Some(main_layer_count)`
37/// when the file has explicit MTP metadata, else `None`.
38fn compute_mtp_layer_threshold(file: &rlx_gguf::GgufFile) -> Option<u32> {
39    let arch = file
40        .metadata
41        .get("general.architecture")
42        .and_then(MetaValue::as_str)?;
43    let block_count = file
44        .metadata
45        .get(&format!("{arch}.block_count"))
46        .and_then(MetaValue::as_u32)?;
47    let nextn = file
48        .metadata
49        .get(&format!("{arch}.nextn_predict_layers"))
50        .and_then(MetaValue::as_u32)?;
51    if nextn == 0 {
52        return None;
53    }
54    Some(block_count.saturating_sub(nextn))
55}
56
57use crate::gguf_resolve::resolve_gguf_tensor_name;
58use crate::gguf_support::gguf_architecture_str;
59use crate::weight_map::PackedWeightTensor;
60use crate::weight_map::WeightMap;
61use rlx_ir::quant::QuantScheme;
62
63/// Translate a Hugging Face / safetensors-convention tensor name to
64/// the GGUF / llama.cpp convention. Returns `None` when no mapping
65/// exists (caller should treat the name as already-GGUF or as an
66/// architecture-specific weight that this mapper doesn't know about,
67/// e.g. MTP heads — see [`is_mtp_weight`]).
68///
69/// The mapping mirrors the table baked into `llama.cpp`'s
70/// `gguf-py/gguf/tensor_mapping.py` for the LLaMA-family architectures
71/// (Qwen3 reuses it). When adding new architectures, prefer extending
72/// this function over forking it.
73pub fn hf_to_gguf_name(hf: &str) -> Option<String> {
74    // Top-level (non-layer) tensors.
75    match hf {
76        "model.embed_tokens.weight" => return Some("token_embd.weight".into()),
77        "model.norm.weight" => return Some("output_norm.weight".into()),
78        "lm_head.weight" => return Some("output.weight".into()),
79        _ => {}
80    }
81    // Layer tensors: `model.layers.{i}.<tail>.weight` → `blk.{i}.<gguf-tail>.weight`.
82    let rest = hf.strip_prefix("model.layers.")?;
83    let dot = rest.find('.')?;
84    let (idx_str, tail_with_dot) = rest.split_at(dot);
85    let tail = &tail_with_dot[1..]; // skip the '.'
86    let idx: usize = idx_str.parse().ok()?;
87    let gguf_tail = match tail {
88        "input_layernorm.weight" => "attn_norm.weight",
89        // Llama-style: `post_attention_layernorm` ≡ pre-FFN norm.
90        // Gemma 2 disagrees (it has 4 distinct norms) and uses a
91        // dedicated `Gemma2GgufResolver` to override this entry. Don't
92        // add Gemma 2 names here — they'd collide with Llama callers.
93        "post_attention_layernorm.weight" => "ffn_norm.weight",
94        "self_attn.q_proj.weight" => "attn_q.weight",
95        "self_attn.k_proj.weight" => "attn_k.weight",
96        "self_attn.v_proj.weight" => "attn_v.weight",
97        "self_attn.o_proj.weight" => "attn_output.weight",
98        "self_attn.q_proj.bias" => "attn_q.bias",
99        "self_attn.k_proj.bias" => "attn_k.bias",
100        "self_attn.v_proj.bias" => "attn_v.bias",
101        "self_attn.q_norm.weight" => "attn_q_norm.weight",
102        "self_attn.k_norm.weight" => "attn_k_norm.weight",
103        "mlp.gate_proj.weight" => "ffn_gate.weight",
104        "mlp.up_proj.weight" => "ffn_up.weight",
105        "mlp.down_proj.weight" => "ffn_down.weight",
106        _ => return None,
107    };
108    Some(format!("blk.{idx}.{gguf_tail}"))
109}
110
111/// Inverse of [`hf_to_gguf_name`] — translate a GGUF / llama.cpp
112/// tensor name back to the safetensors / HuggingFace convention. Used
113/// by drain-style loaders (e.g. `Qwen3Generator::from_loader`) that
114/// cache weights by name and need the cache key to match what the
115/// builder will ask for.
116pub fn gguf_to_hf_name(gguf: &str) -> Option<String> {
117    match gguf {
118        "token_embd.weight" => return Some("model.embed_tokens.weight".into()),
119        "output_norm.weight" => return Some("model.norm.weight".into()),
120        "output.weight" => return Some("lm_head.weight".into()),
121        _ => {}
122    }
123    let rest = gguf.strip_prefix("blk.")?;
124    let dot = rest.find('.')?;
125    let (idx_str, tail_with_dot) = rest.split_at(dot);
126    let tail = &tail_with_dot[1..];
127    let idx: usize = idx_str.parse().ok()?;
128    let hf_tail = match tail {
129        "attn_norm.weight" => "input_layernorm.weight",
130        "ffn_norm.weight" => "post_attention_layernorm.weight",
131        "attn_q.weight" => "self_attn.q_proj.weight",
132        "attn_k.weight" => "self_attn.k_proj.weight",
133        "attn_v.weight" => "self_attn.v_proj.weight",
134        "attn_output.weight" => "self_attn.o_proj.weight",
135        "attn_q.bias" => "self_attn.q_proj.bias",
136        "attn_k.bias" => "self_attn.k_proj.bias",
137        "attn_v.bias" => "self_attn.v_proj.bias",
138        "attn_q_norm.weight" => "self_attn.q_norm.weight",
139        "attn_k_norm.weight" => "self_attn.k_norm.weight",
140        "ffn_gate.weight" => "mlp.gate_proj.weight",
141        "ffn_up.weight" => "mlp.up_proj.weight",
142        "ffn_down.weight" => "mlp.down_proj.weight",
143        _ => return None,
144    };
145    Some(format!("model.layers.{idx}.{hf_tail}"))
146}
147
148/// Arch-aware variant of [`gguf_to_hf_name`]. Falls back to the
149/// generic mapping when `arch` doesn't carry overrides; for arches
150/// whose 1↔1 alias disagrees with the Llama convention (Gemma 2/3/4:
151/// `ffn_norm` is the pre-FFN norm, not the post-attention norm), it
152/// returns the arch-correct HF name instead. Used by drain-style
153/// `from_loader` paths to compute cache keys that match what the
154/// builder will ask for.
155pub fn gguf_to_hf_name_for_arch(gguf: &str, arch: &str) -> Option<String> {
156    if matches!(
157        arch,
158        "gemma2" | "gemma3" | "gemma3n" | "gemma4" | "gemma4moe"
159    ) {
160        match gguf {
161            "token_embd.weight" => return Some("model.embed_tokens.weight".into()),
162            "output_norm.weight" => return Some("model.norm.weight".into()),
163            "output.weight" => return Some("lm_head.weight".into()),
164            _ => {}
165        }
166        let rest = gguf.strip_prefix("blk.")?;
167        let dot = rest.find('.')?;
168        let (idx_str, tail_with_dot) = rest.split_at(dot);
169        let tail = &tail_with_dot[1..];
170        let idx: usize = idx_str.parse().ok()?;
171        let hf_tail = match tail {
172            "attn_norm.weight" => "input_layernorm.weight",
173            "post_attention_norm.weight" => "post_attention_layernorm.weight",
174            "ffn_norm.weight" => "pre_feedforward_layernorm.weight",
175            "post_ffw_norm.weight" => "post_feedforward_layernorm.weight",
176            "attn_q.weight" => "self_attn.q_proj.weight",
177            "attn_k.weight" => "self_attn.k_proj.weight",
178            "attn_v.weight" => "self_attn.v_proj.weight",
179            "attn_output.weight" => "self_attn.o_proj.weight",
180            // Gemma 4 per-head Q/K RMSNorms — applied between
181            // q_proj/k_proj and RoPE. Without this mapping the drain
182            // stored the weights under their GGUF names and the
183            // packed builder's `model.layers.N.self_attn.{q,k}_norm`
184            // lookup missed, panicking with "packed cache miss …
185            // self_attn.q_norm.weight" once the builder started
186            // requesting them (task #50).
187            "attn_q_norm.weight" => "self_attn.q_norm.weight",
188            "attn_k_norm.weight" => "self_attn.k_norm.weight",
189            // Gemma 4 per-layer output scalar (shape [1]) — multiplied
190            // with the layer output before the residual add.
191            "layer_output_scale.weight" => "self_attn.output_scale.weight",
192            "ffn_gate.weight" => "mlp.gate_proj.weight",
193            "ffn_up.weight" => "mlp.up_proj.weight",
194            "ffn_down.weight" => "mlp.down_proj.weight",
195            _ => return None,
196        };
197        return Some(format!("model.layers.{idx}.{hf_tail}"));
198    }
199    if matches!(arch, "phi3" | "phi4") {
200        match gguf {
201            "token_embd.weight" => return Some("model.embed_tokens.weight".into()),
202            "output_norm.weight" => return Some("model.norm.weight".into()),
203            "output.weight" => return Some("lm_head.weight".into()),
204            _ => {}
205        }
206        let rest = gguf.strip_prefix("blk.")?;
207        let dot = rest.find('.')?;
208        let (idx_str, tail_with_dot) = rest.split_at(dot);
209        let tail = &tail_with_dot[1..];
210        let idx: usize = idx_str.parse().ok()?;
211        let hf_tail = match tail {
212            "attn_norm.weight" => "input_layernorm.weight",
213            "ffn_norm.weight" => "post_attention_layernorm.weight",
214            "attn_qkv.weight" => "self_attn.qkv.weight",
215            "attn_output.weight" => "self_attn.o_proj.weight",
216            "ffn_up.weight" => "mlp.gate_up.weight",
217            "ffn_down.weight" => "mlp.down_proj.weight",
218            _ => return None,
219        };
220        return Some(format!("model.layers.{idx}.{hf_tail}"));
221    }
222    gguf_to_hf_name(gguf)
223}
224
225/// Arch-aware inverse of [`gguf_to_hf_name_for_arch`]. Used by
226/// [`crate::gguf_resolve::Gemma2GgufResolver`] when builders request
227/// HF tensor names against a Gemma 2/3/4 GGUF file.
228pub fn hf_to_gguf_name_for_arch(hf: &str, arch: &str) -> Option<String> {
229    if matches!(
230        arch,
231        "gemma2" | "gemma3" | "gemma3n" | "gemma4" | "gemma4moe"
232    ) {
233        match hf {
234            "model.embed_tokens.weight" => return Some("token_embd.weight".into()),
235            "model.norm.weight" => return Some("output_norm.weight".into()),
236            "lm_head.weight" => return Some("output.weight".into()),
237            _ => {}
238        }
239        let rest = hf.strip_prefix("model.layers.")?;
240        let dot = rest.find('.')?;
241        let (idx_str, tail_with_dot) = rest.split_at(dot);
242        let tail = &tail_with_dot[1..];
243        let idx: usize = idx_str.parse().ok()?;
244        let gguf_tail = match tail {
245            "input_layernorm.weight" => "attn_norm.weight",
246            "post_attention_layernorm.weight" => "post_attention_norm.weight",
247            "pre_feedforward_layernorm.weight" => "ffn_norm.weight",
248            "post_feedforward_layernorm.weight" => "post_ffw_norm.weight",
249            "self_attn.q_proj.weight" => "attn_q.weight",
250            "self_attn.k_proj.weight" => "attn_k.weight",
251            "self_attn.v_proj.weight" => "attn_v.weight",
252            "self_attn.o_proj.weight" => "attn_output.weight",
253            "self_attn.q_norm.weight" => "attn_q_norm.weight",
254            "self_attn.k_norm.weight" => "attn_k_norm.weight",
255            "self_attn.output_scale.weight" => "layer_output_scale.weight",
256            "mlp.gate_proj.weight" => "ffn_gate.weight",
257            "mlp.up_proj.weight" => "ffn_up.weight",
258            "mlp.down_proj.weight" => "ffn_down.weight",
259            _ => return hf_to_gguf_name(hf),
260        };
261        return Some(format!("blk.{idx}.{gguf_tail}"));
262    }
263    if matches!(arch, "phi3" | "phi4") {
264        match hf {
265            "model.embed_tokens.weight" => return Some("token_embd.weight".into()),
266            "model.norm.weight" => return Some("output_norm.weight".into()),
267            "lm_head.weight" => return Some("output.weight".into()),
268            _ => {}
269        }
270        let rest = hf.strip_prefix("model.layers.")?;
271        let dot = rest.find('.')?;
272        let (idx_str, tail_with_dot) = rest.split_at(dot);
273        let tail = &tail_with_dot[1..];
274        let idx: usize = idx_str.parse().ok()?;
275        let gguf_tail = match tail {
276            "input_layernorm.weight" => "attn_norm.weight",
277            "post_attention_layernorm.weight" => "ffn_norm.weight",
278            "self_attn.qkv.weight" => "attn_qkv.weight",
279            "self_attn.o_proj.weight" => "attn_output.weight",
280            "mlp.gate_up.weight" => "ffn_up.weight",
281            "mlp.down_proj.weight" => "ffn_down.weight",
282            _ => return hf_to_gguf_name(hf),
283        };
284        return Some(format!("blk.{idx}.{gguf_tail}"));
285    }
286    hf_to_gguf_name(hf)
287}
288
289/// Match GGUF tensor names that hold a Gemma RMSNorm gain. Covers all
290/// four per-layer norms in the V2/V3/V4 sandwich (attn / post_attention
291/// / ffn / post_ffw), per-head Q/K norms, plus the final `output_norm`,
292/// in both GGUF-native and HF spellings — drain order is undefined, so
293/// we may see either convention at the call site.
294///
295/// `convert_hf_to_gguf` / `gemma.py` bakes `(1 + gamma)` into every
296/// `*norm.weight` (including `attn_q_norm` / `attn_k_norm`). Subtract
297/// 1 on `take` so `gemma_rms`'s `1 + w` matches llama.cpp for all norms.
298fn is_gemma_norm_weight(name: &str) -> bool {
299    if name == "output_norm.weight" || name == "model.norm.weight" {
300        return true;
301    }
302    if let Some(rest) = name
303        .strip_prefix("blk.")
304        .and_then(|r| r.split_once('.').map(|x| x.1))
305    {
306        return matches!(
307            rest,
308            "attn_norm.weight"
309                | "post_attention_norm.weight"
310                | "ffn_norm.weight"
311                | "post_ffw_norm.weight"
312                | "attn_q_norm.weight"
313                | "attn_k_norm.weight"
314        );
315    }
316    if let Some(rest) = name
317        .strip_prefix("model.layers.")
318        .and_then(|r| r.split_once('.').map(|x| x.1))
319    {
320        return matches!(
321            rest,
322            "input_layernorm.weight"
323                | "post_attention_layernorm.weight"
324                | "pre_feedforward_layernorm.weight"
325                | "post_feedforward_layernorm.weight"
326                | "self_attn.q_norm.weight"
327                | "self_attn.k_norm.weight"
328        );
329    }
330    false
331}
332
333/// True if the GGUF tensor name **looks like** a Multi-Token
334/// Prediction head by its name alone — substring match on
335/// `mtp_*` / `*.mtp` / `output_mtp_*` style. Covers MTP variants
336/// that name their heads explicitly.
337///
338/// **NOT enough on its own** for the most common unsloth /
339/// DeepSeek-V3 convention, which encodes MTP heads as *extra
340/// `blk.N` layers* with N beyond the main `block_count`. For that
341/// case use [`GgufLoader::mtp_layer_threshold`] / the loader's
342/// `is_mtp_tensor` instance method — they read
343/// `{arch}.nextn_predict_layers` from the GGUF metadata and treat
344/// trailing `blk.*` indices accordingly.
345pub fn is_mtp_weight(name: &str) -> bool {
346    name.contains("mtp_") || name.contains(".mtp") || name.starts_with("mtp")
347}
348
349/// Map GGML storage type to RLX packed matmul scheme (K-quants only).
350pub fn ggml_type_to_quant_scheme(dtype: rlx_gguf::GgmlType) -> Option<QuantScheme> {
351    use rlx_gguf::GgmlType;
352    match dtype {
353        GgmlType::Q2K => Some(QuantScheme::GgufQ2K),
354        GgmlType::Q3K => Some(QuantScheme::GgufQ3K),
355        GgmlType::Q4K => Some(QuantScheme::GgufQ4K),
356        GgmlType::Q5K => Some(QuantScheme::GgufQ5K),
357        GgmlType::Q6K => Some(QuantScheme::GgufQ6K),
358        GgmlType::Q8K => Some(QuantScheme::GgufQ8K),
359        GgmlType::Q4_0 => Some(QuantScheme::GgufQ4_0),
360        GgmlType::Q5_0 => Some(QuantScheme::GgufQ5_0),
361        GgmlType::Q8_0 => Some(QuantScheme::GgufQ8_0),
362        _ => None,
363    }
364}
365
366/// Whether [`QuantScheme`] may stay packed in `Op::DequantMatMul` graphs.
367///
368/// `GgufQ6K` requires `rlx-gguf` ≥ 0.2.1 with signed scale bytes in
369/// [`rlx_gguf::dequant_q6_k_block`] (crates.io 0.2.1 used `as f32` and
370/// skewed v_proj / down_proj). When the block path disagrees with
371/// [`rlx_gguf::dequant_q6_k`], callers fall back to F32
372/// [`WeightLoader::take_transposed`].
373pub fn dequant_matmul_supported(scheme: QuantScheme) -> bool {
374    match scheme {
375        QuantScheme::GgufQ6K => q6k_dequant_matmul_supported(),
376        _ => true,
377    }
378}
379
380fn q6k_dequant_matmul_supported() -> bool {
381    static OK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
382    *OK.get_or_init(probe_q6k_block_dequant)
383}
384
385/// Synthetic Q6_K block: d=1, scale byte 0xFF (i8 −1), q=1 at slot 0.
386fn probe_q6k_block_dequant() -> bool {
387    use rlx_gguf::{QK_K, dequant_q6_k, dequant_q6_k_block};
388    const BLK: usize = QK_K / 2 + QK_K / 4 + QK_K / 16 + 2;
389    let mut block = [0u8; BLK];
390    let sc_off = QK_K / 2 + QK_K / 4;
391    block[sc_off] = 0xFF;
392    block[0] = 0x21;
393    block[QK_K / 2] = 0x08;
394    block[BLK - 2..].copy_from_slice(&half::f16::ONE.to_le_bytes());
395
396    let mut out_block = [0f32; QK_K];
397    dequant_q6_k_block(&block, &mut out_block);
398    let full = match dequant_q6_k(&block, QK_K) {
399        Ok(v) => v,
400        Err(_) => return false,
401    };
402    (out_block[0] - full[0]).abs() < 1e-4
403}
404
405/// Common interface every weight format must satisfy. Mirrors the
406/// existing `WeightMap` API so the safetensors impl is a one-line
407/// adapter.
408///
409/// Register additional on-disk formats with [`crate::weight_registry::register_weight_format`].
410pub trait WeightLoader: Send {
411    /// Format id (`safetensors`, `gguf`, or a custom registration).
412    fn format_id(&self) -> &'static str {
413        "unknown"
414    }
415    /// Number of distinct weights in the file.
416    fn len(&self) -> usize;
417    fn is_empty(&self) -> bool {
418        self.len() == 0
419    }
420    /// Take the named tensor as `(f32_data, shape)`. Removes from the
421    /// loader so callers can detect "weights I never used."
422    fn take(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)>;
423    /// Same as `take` but transposed (last two dims swapped). Most
424    /// safetensors weights are stored row-major-of-PyTorch
425    /// convention, which RLX's IR consumes column-major; this helper
426    /// is the convention-bridge.
427    fn take_transposed(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)>;
428    /// Take packed K-quant bytes when supported; default returns `None`.
429    fn take_packed(&mut self, key: &str) -> Result<Option<crate::weight_map::PackedWeightTensor>> {
430        let _ = key;
431        Ok(None)
432    }
433    /// Borrow packed bytes without marking taken (GGUF mmap path).
434    fn tensor_bytes_borrowed(&self, key: &str) -> Option<&[u8]> {
435        let _ = key;
436        None
437    }
438    /// Names that haven't been taken yet — useful for "did the
439    /// model use every weight?" hygiene checks.
440    fn remaining_keys(&self) -> Vec<String>;
441    /// Architecture name from the underlying file (`general.architecture`
442    /// for GGUF, `None` for safetensors). Drain-style consumers use this
443    /// to pick an arch-specific reverse name mapping when the canonical
444    /// HF name depends on the model family (e.g. Gemma 2's 4 norms per
445    /// layer don't share the Llama 2-norm reverse alias).
446    fn arch_hint(&self) -> Option<&str> {
447        None
448    }
449}
450
451impl WeightLoader for WeightMap {
452    fn format_id(&self) -> &'static str {
453        "safetensors"
454    }
455    fn len(&self) -> usize {
456        Self::len(self)
457    }
458    fn take(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
459        Self::take(self, key)
460    }
461    fn take_transposed(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
462        Self::take_transposed(self, key)
463    }
464    fn remaining_keys(&self) -> Vec<String> {
465        self.keys().map(|s| s.to_string()).collect()
466    }
467}
468
469/// F32 tensor stored in a shared host cache (`Arc` avoids cloning multi-GB
470/// weights on every graph build).
471pub type ArcF32Tensor = (Arc<Vec<f32>>, Vec<usize>);
472
473/// [`WeightLoader`] view over a frozen `HashMap` of [`ArcF32Tensor`].
474/// Graph builders [`take`](WeightLoader::take) tensors out of this view
475/// (one `Vec` copy per weight into compile params) without cloning the
476/// entire cache first.
477pub struct ArcCacheLoader<'a> {
478    cache: &'a HashMap<String, ArcF32Tensor>,
479}
480
481impl<'a> ArcCacheLoader<'a> {
482    pub fn new(cache: &'a HashMap<String, ArcF32Tensor>) -> Self {
483        Self { cache }
484    }
485}
486
487impl WeightLoader for ArcCacheLoader<'_> {
488    fn format_id(&self) -> &'static str {
489        "arc-cache"
490    }
491    fn len(&self) -> usize {
492        self.cache.len()
493    }
494    fn take(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
495        let (data, shape) = self
496            .cache
497            .get(key)
498            .ok_or_else(|| anyhow!("weight not found in arc cache: {key}"))?;
499        Ok((data.as_ref().clone(), shape.clone()))
500    }
501    fn take_transposed(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
502        let (data, shape) = self.take(key)?;
503        if shape.len() != 2 {
504            anyhow::bail!("transpose requires 2D, got {shape:?}");
505        }
506        let (rows, cols) = (shape[0], shape[1]);
507        let mut transposed = vec![0f32; data.len()];
508        for i in 0..rows {
509            for j in 0..cols {
510                transposed[j * rows + i] = data[i * cols + j];
511            }
512        }
513        Ok((transposed, vec![cols, rows]))
514    }
515    fn remaining_keys(&self) -> Vec<String> {
516        self.cache.keys().cloned().collect()
517    }
518}
519
520/// Adapter that lets a HF-safetensors-backed [`WeightLoader`] satisfy
521/// requests phrased in GGUF-style names (`blk.N.attn_q.weight` etc.).
522///
523/// Builders like [`Qwen35Weights::from_loader`] address tensors using
524/// the GGUF / llama.cpp convention; the underlying safetensors file
525/// stores them under HF / PyTorch names (`model.layers.N.self_attn.q_proj.weight`).
526/// This wrapper:
527///
528/// 1. Tries the requested key verbatim (in case it's already-HF or
529///    the file was named GGUF-style).
530/// 2. Tries [`gguf_to_hf_name`] to translate the GGUF key → HF key.
531/// 3. Returns the underlying loader's error otherwise.
532pub struct HfTranslatingLoader<L: WeightLoader> {
533    inner: L,
534}
535
536impl<L: WeightLoader> HfTranslatingLoader<L> {
537    pub fn new(inner: L) -> Self {
538        Self { inner }
539    }
540    pub fn into_inner(self) -> L {
541        self.inner
542    }
543    pub fn inner(&self) -> &L {
544        &self.inner
545    }
546    pub fn inner_mut(&mut self) -> &mut L {
547        &mut self.inner
548    }
549}
550
551impl<L: WeightLoader> WeightLoader for HfTranslatingLoader<L> {
552    fn format_id(&self) -> &'static str {
553        self.inner.format_id()
554    }
555    fn len(&self) -> usize {
556        self.inner.len()
557    }
558    fn take(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
559        match self.inner.take(key) {
560            Ok(v) => Ok(v),
561            Err(_) => {
562                if let Some(hf) = gguf_to_hf_name(key) {
563                    return self.inner.take(&hf);
564                }
565                self.inner.take(key)
566            }
567        }
568    }
569    fn take_transposed(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
570        match self.inner.take_transposed(key) {
571            Ok(v) => Ok(v),
572            Err(_) => {
573                if let Some(hf) = gguf_to_hf_name(key) {
574                    return self.inner.take_transposed(&hf);
575                }
576                self.inner.take_transposed(key)
577            }
578        }
579    }
580    fn take_packed(&mut self, key: &str) -> Result<Option<crate::weight_map::PackedWeightTensor>> {
581        self.inner.take_packed(key)
582    }
583    fn tensor_bytes_borrowed(&self, key: &str) -> Option<&[u8]> {
584        self.inner.tensor_bytes_borrowed(key)
585    }
586    fn remaining_keys(&self) -> Vec<String> {
587        self.inner.remaining_keys()
588    }
589}
590
591/// Dispatch on the file extension via [`crate::weight_registry`].
592pub fn load_from_path(path: &str) -> Result<Box<dyn WeightLoader>> {
593    crate::weight_registry::open_weight_loader(Path::new(path))
594}
595
596// ─── GGUF adapter ─────────────────────────────────────────────────
597//
598// Wraps `rlx_gguf::GgufFile` so it satisfies `WeightLoader`. Tracks
599// taken keys in a side-set since `dequant_f32` borrows the file
600// immutably; the alternative — pre-decoding every tensor at load
601// time — defeats the point of GGUF's lazy block layout.
602
603pub struct GgufLoader {
604    file: rlx_gguf::GgufFile,
605    arch: String,
606    taken: HashSet<String>,
607    /// When true, `remaining_keys` / `len` / `take` treat MTP-head
608    /// weights as ordinary tensors instead of hiding them. The base
609    /// qwen3 builder ignores MTP tensors regardless — this flag
610    /// only changes the *visibility* in the `WeightLoader` surface
611    /// so downstream MTP-aware builders can iterate them through
612    /// the standard drain pattern.
613    include_mtp: bool,
614    /// First `blk.N` index that belongs to an MTP head, computed
615    /// from `{arch}.block_count - {arch}.nextn_predict_layers` at
616    /// construction. `None` for files without the metadata key
617    /// (= no MTP heads encoded as trailing blocks).
618    mtp_layer_threshold: Option<u32>,
619}
620
621impl GgufLoader {
622    pub fn from_file(path: &str) -> Result<Self> {
623        let file = crate::gguf_support::load_gguf_file(std::path::Path::new(path))?;
624        let arch = gguf_architecture_str(&file)
625            .unwrap_or("unknown")
626            .to_string();
627        let mtp_layer_threshold = compute_mtp_layer_threshold(&file);
628        Ok(Self {
629            file,
630            arch,
631            taken: HashSet::new(),
632            include_mtp: false,
633            mtp_layer_threshold,
634        })
635    }
636
637    pub fn architecture(&self) -> &str {
638        &self.arch
639    }
640
641    /// First `blk.N` index that the GGUF metadata reports as an MTP
642    /// head, derived from `{arch}.block_count -
643    /// {arch}.nextn_predict_layers`. `None` for files where the
644    /// `nextn_predict_layers` key is absent (= no MTP, or MTP is
645    /// encoded under a different naming scheme — fall back to
646    /// [`is_mtp_weight`] in that case).
647    pub fn mtp_layer_threshold(&self) -> Option<u32> {
648        self.mtp_layer_threshold
649    }
650
651    /// Borrow the underlying parsed `GgufFile` so callers (e.g. arch
652    /// builders that read `general.architecture`-specific keys)
653    /// don't have to re-parse 800+ tensor headers a second time.
654    pub fn file(&self) -> &rlx_gguf::GgufFile {
655        &self.file
656    }
657
658    /// Borrow the raw on-disk byte slice for a tensor without
659    /// marking it taken. Returns `None` if the key doesn't resolve
660    /// or the byte range is invalid. Used by the qwen35 packed-
661    /// upload path to stream K-quant bytes from mmap straight into
662    /// the compiled arena, skipping a per-tensor `Vec<u8>`
663    /// allocation (≈ 16 GB on Qwen3.6-27B Q4_K_M).
664    pub fn tensor_bytes_borrowed(&self, key: &str) -> Option<&[u8]> {
665        let real = self.resolve(key).ok()?;
666        let t = self.file.get(&real)?;
667        self.file.tensor_bytes(t).ok()
668    }
669
670    /// Variant of [`Self::take_packed`] that returns only the
671    /// `(scheme, shape)` metadata without copying bytes. The caller
672    /// uploads bytes separately via [`Self::tensor_bytes_borrowed`]
673    /// after the graph is compiled — eliminates the per-tensor
674    /// `Vec<u8>` allocation. Marks the tensor taken on success;
675    /// returns `Ok(None)` for non-K-quant dtypes so the caller can
676    /// fall back to the dequant path.
677    pub fn take_packed_metadata(
678        &mut self,
679        key: &str,
680    ) -> Result<Option<(rlx_ir::quant::QuantScheme, Vec<usize>)>> {
681        let real = self.resolve(key)?;
682        if self.taken.contains(&real) {
683            return Err(anyhow!("weight already taken: {key} (→ {real})"));
684        }
685        if !self.include_mtp && self.is_mtp_tensor(&real) {
686            return Err(anyhow!(
687                "refusing to take MTP weight `{real}` without include_mtp(true)"
688            ));
689        }
690        let t = self
691            .file
692            .get(&real)
693            .ok_or_else(|| anyhow!("tensor missing: {real}"))?;
694        let Some(scheme) = ggml_type_to_quant_scheme(t.dtype) else {
695            return Ok(None);
696        };
697        if !dequant_matmul_supported(scheme) {
698            return Ok(None);
699        }
700        let mut shape = t.shape.clone();
701        shape.reverse();
702        self.taken.insert(real);
703        Ok(Some((scheme, shape)))
704    }
705
706    /// True if `name` is an MTP weight under this file's naming
707    /// scheme. Combines the substring heuristic ([`is_mtp_weight`])
708    /// with the model-aware `blk.N where N >= threshold` check.
709    pub fn is_mtp_tensor(&self, name: &str) -> bool {
710        if is_mtp_weight(name) {
711            return true;
712        }
713        if let Some(thresh) = self.mtp_layer_threshold {
714            if let Some(rest) = name.strip_prefix("blk.") {
715                if let Some(dot) = rest.find('.') {
716                    if let Ok(idx) = rest[..dot].parse::<u32>() {
717                        if idx >= thresh {
718                            return true;
719                        }
720                    }
721                }
722            }
723        }
724        false
725    }
726
727    /// Toggle MTP-weight visibility. With `include = true`, MTP
728    /// heads show up in `remaining_keys()` (and count toward `len()`)
729    /// — drain-style consumers like
730    /// `Qwen3Generator::from_loader` will then pull them into the
731    /// weights cache. Default off so non-MTP models behave exactly
732    /// as before. Call this before any `take()` / drain so the
733    /// inclusion choice is consistent across the load.
734    pub fn include_mtp(&mut self, include: bool) -> &mut Self {
735        self.include_mtp = include;
736        self
737    }
738
739    /// Take a tensor's **packed bytes** (no dequant), plus its
740    /// [`rlx_ir::quant::QuantScheme`] and safetensors-style shape.
741    /// Returns `None` when the tensor is stored uncompressed
742    /// (F32/F16/BF16) — caller should fall back to `take()` for
743    /// those.
744    ///
745    /// Used by the qwen3 builder's *packed-weights mode*: the LM
746    /// head + per-layer matmul weights stay in the arena as raw
747    /// K-quant bytes, and the graph emits
748    /// `Op::DequantMatMul { scheme }` instead of `Op::MatMul` for
749    /// them. Cuts the load-time memory footprint by ~7-9× on
750    /// Q4_K_M / Q6_K models — the unblocker for ≥14 B Qwen3 / Llama
751    /// GGUFs on commodity Macs.
752    pub fn take_packed(&mut self, key: &str) -> Result<Option<PackedWeightTensor>> {
753        let real = self.resolve(key)?;
754        if self.taken.contains(&real) {
755            return Err(anyhow!("weight already taken: {key} (→ {real})"));
756        }
757        if !self.include_mtp && self.is_mtp_tensor(&real) {
758            return Err(anyhow!(
759                "refusing to take MTP weight `{real}` without include_mtp(true)"
760            ));
761        }
762        let t = self
763            .file
764            .get(&real)
765            .ok_or_else(|| anyhow!("tensor missing: {real}"))?;
766        // Map ggml dtype → our QuantScheme. K-quants and Q4_0/Q5_0/Q8_0 can stay
767        // packed on CPU; Q4_1/Q5_1 + uncompressed F32/F16/BF16 fall through
768        // F32/F16/BF16 fall through to the dequant path (return
769        // None — caller switches to `take`).
770        let Some(scheme) = ggml_type_to_quant_scheme(t.dtype) else {
771            return Ok(None);
772        };
773        if !dequant_matmul_supported(scheme) {
774            return Ok(None);
775        };
776        let bytes = self
777            .file
778            .tensor_bytes(t)
779            .with_context(|| format!("read packed bytes for {real}"))?
780            .to_vec();
781        let mut shape = t.shape.clone();
782        // Match the safetensors-style shape ordering applied by
783        // `take` — GGUF stores innermost-first, safetensors stores
784        // outermost-first; byte layout is identical.
785        shape.reverse();
786        self.taken.insert(real);
787        Ok(Some((bytes, scheme, shape)))
788    }
789
790    /// Take a single MTP weight by name. Bypasses the `include_mtp`
791    /// filter so callers can grab specific heads without flipping
792    /// the global visibility. Returns an error if the name isn't a
793    /// recognized MTP weight (use [`take`] for non-MTP keys).
794    pub fn take_mtp(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
795        if !self.is_mtp_tensor(key) {
796            return Err(anyhow!("not an MTP weight under this file's scheme: {key}"));
797        }
798        if !self.file.tensors.contains_key(key) {
799            return Err(anyhow!("MTP weight not found in GGUF: {key}"));
800        }
801        if self.taken.contains(key) {
802            return Err(anyhow!("MTP weight already taken: {key}"));
803        }
804        let (data, raw_shape) = self.file.dequant_f32(key)?;
805        self.taken.insert(key.to_string());
806        let mut shape = raw_shape;
807        shape.reverse();
808        Ok((data, shape))
809    }
810}
811
812impl GgufLoader {
813    /// Resolve a caller-supplied key (HF or GGUF naming) to the
814    /// actual GGUF tensor name via registered architecture resolvers.
815    fn resolve(&self, key: &str) -> Result<String> {
816        resolve_gguf_tensor_name(&self.file, &self.arch, key)
817            .ok_or_else(|| anyhow!("weight not found in GGUF (arch={}): {key}", self.arch))
818    }
819}
820
821impl WeightLoader for GgufLoader {
822    fn format_id(&self) -> &'static str {
823        "gguf"
824    }
825    fn arch_hint(&self) -> Option<&str> {
826        Some(&self.arch)
827    }
828    fn take_packed(&mut self, key: &str) -> Result<Option<crate::weight_map::PackedWeightTensor>> {
829        self.take_packed(key)
830    }
831    fn tensor_bytes_borrowed(&self, key: &str) -> Option<&[u8]> {
832        GgufLoader::tensor_bytes_borrowed(self, key)
833    }
834    fn len(&self) -> usize {
835        self.file
836            .tensors
837            .keys()
838            .filter(|k| !self.taken.contains(*k) && (self.include_mtp || !self.is_mtp_tensor(k)))
839            .count()
840    }
841    fn take(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
842        let real = self.resolve(key)?;
843        if self.taken.contains(&real) {
844            return Err(anyhow!("weight already taken: {key} (→ {real})"));
845        }
846        if !self.include_mtp && self.is_mtp_tensor(&real) {
847            return Err(anyhow!(
848                "refusing to take MTP weight `{real}` without include_mtp(true); \
849                 use loader.take_mtp(...) for explicit MTP grabs or \
850                 loader.include_mtp(true) to include them in drains"
851            ));
852        }
853        let (mut data, raw_shape) = self.file.dequant_f32(&real)?;
854        self.taken.insert(real.clone());
855        // Gemma's GGUF conversion script bakes the `(1 + gamma)` offset
856        // into every norm weight (see llama.cpp `convert_hf_to_gguf.py`
857        // → `GemmaModel.modify_tensors`). HF/safetensors stores raw
858        // gamma. Subtract 1 here so the loader publishes the
859        // safetensors convention and `GemmaRmsNormStage`'s explicit
860        // `+1` stays correct for both sources. Without this the RMS
861        // gain is systematically inflated and logits skew (cosine
862        // ~0.7 vs llama.cpp; structural, not numerical noise).
863        if matches!(
864            self.arch.as_str(),
865            "gemma" | "gemma2" | "gemma3" | "gemma3n" | "gemma4"
866        ) && is_gemma_norm_weight(&real)
867        {
868            for v in data.iter_mut() {
869                *v -= 1.0;
870            }
871        }
872        // GGUF/ggml report tensor shapes innermost-first (`ne[0]` is
873        // the fastest-varying dim) while safetensors reports outermost-
874        // first. The actual byte layout is identical row-major — only
875        // the shape label is reversed. Reverse to match safetensors so
876        // existing builders work unchanged; no data movement.
877        let mut shape = raw_shape;
878        shape.reverse();
879        Ok((data, shape))
880    }
881    /// **BREAKING CHANGE in 0.2.0:** prior to 0.2.0 this method was
882    /// a no-op for GGUF (returned the bytes unchanged with the GGUF
883    /// shape label) which silently produced garbage logits when the
884    /// builder expected `[in, out]` row-major. From 0.2.0 onwards
885    /// `take` normalizes GGUF's reverse-shape convention so this
886    /// method matches the safetensors variant byte-for-byte.
887    /// Downstream code that explicitly worked around the old buggy
888    /// behavior (manually re-transposing the result) must drop that
889    /// workaround.
890    fn take_transposed(&mut self, key: &str) -> Result<(Vec<f32>, Vec<usize>)> {
891        // After the safetensors normalization in `take`, this matches
892        // the WeightMap implementation byte-for-byte.
893        let (data, shape) = self.take(key)?;
894        // A 1D tensor (e.g. Gemma 4's AltUp/Laurel/per-layer scale vectors that
895        // live under .self_attn./.mlp. namespaces) transposes to itself — return
896        // as-is instead of erroring so the packed drain doesn't abort.
897        if shape.len() == 1 {
898            return Ok((data, shape));
899        }
900        if shape.len() != 2 {
901            return Err(anyhow!("transpose requires 2D, got {shape:?}"));
902        }
903        let (rows, cols) = (shape[0], shape[1]);
904        let mut t = vec![0f32; data.len()];
905        for i in 0..rows {
906            for j in 0..cols {
907                t[j * rows + i] = data[i * cols + j];
908            }
909        }
910        Ok((t, vec![cols, rows]))
911    }
912    fn remaining_keys(&self) -> Vec<String> {
913        // MTP weights default to invisible — they belong to optional
914        // speculative heads and the base qwen3 builder ignores them.
915        // Callers wanting MTP-aware loading flip `include_mtp(true)`
916        // first, which surfaces them here.
917        self.file
918            .tensors
919            .keys()
920            .filter(|k| {
921                !self.taken.contains(k.as_str()) && (self.include_mtp || !self.is_mtp_tensor(k))
922            })
923            .cloned()
924            .collect()
925    }
926}
927
928impl GgufLoader {
929    /// Tensor names that look like MTP heads under this file's
930    /// scheme (combines the substring heuristic with the
931    /// model-aware `blk.N where N >= threshold` check — see
932    /// [`is_mtp_tensor`](Self::is_mtp_tensor)).
933    /// Returned unfiltered by `remaining_keys` so consumers wanting
934    /// to wire MTP can find them explicitly.
935    pub fn mtp_keys(&self) -> Vec<String> {
936        self.file
937            .tensors
938            .keys()
939            .filter(|k| self.is_mtp_tensor(k))
940            .cloned()
941            .collect()
942    }
943}
944
945#[cfg(test)]
946mod tests {
947    use super::*;
948
949    #[test]
950    fn unknown_extension_errors() {
951        let r = load_from_path("/tmp/no-such-thing.bin");
952        match r {
953            Err(e) => assert!(e.to_string().contains("unsupported")),
954            Ok(_) => panic!("expected error"),
955        }
956    }
957
958    #[test]
959    fn gemma_qk_norm_names_get_gguf_gamma_unbake() {
960        assert!(is_gemma_norm_weight("blk.0.attn_q_norm.weight"));
961        assert!(is_gemma_norm_weight("blk.7.attn_k_norm.weight"));
962        assert!(is_gemma_norm_weight(
963            "model.layers.0.self_attn.q_norm.weight"
964        ));
965        assert!(is_gemma_norm_weight(
966            "model.layers.3.self_attn.k_norm.weight"
967        ));
968        assert!(!is_gemma_norm_weight("blk.0.attn_q.weight"));
969    }
970
971    #[test]
972    fn hf_to_gguf_top_level() {
973        assert_eq!(
974            hf_to_gguf_name("model.embed_tokens.weight").as_deref(),
975            Some("token_embd.weight")
976        );
977        assert_eq!(
978            hf_to_gguf_name("model.norm.weight").as_deref(),
979            Some("output_norm.weight")
980        );
981        assert_eq!(
982            hf_to_gguf_name("lm_head.weight").as_deref(),
983            Some("output.weight")
984        );
985    }
986
987    #[test]
988    fn hf_to_gguf_per_layer() {
989        let cases = [
990            (
991                "model.layers.0.self_attn.q_proj.weight",
992                "blk.0.attn_q.weight",
993            ),
994            (
995                "model.layers.7.self_attn.o_proj.weight",
996                "blk.7.attn_output.weight",
997            ),
998            (
999                "model.layers.3.mlp.gate_proj.weight",
1000                "blk.3.ffn_gate.weight",
1001            ),
1002            (
1003                "model.layers.12.mlp.down_proj.weight",
1004                "blk.12.ffn_down.weight",
1005            ),
1006            (
1007                "model.layers.4.input_layernorm.weight",
1008                "blk.4.attn_norm.weight",
1009            ),
1010            (
1011                "model.layers.4.post_attention_layernorm.weight",
1012                "blk.4.ffn_norm.weight",
1013            ),
1014            (
1015                "model.layers.0.self_attn.q_norm.weight",
1016                "blk.0.attn_q_norm.weight",
1017            ),
1018        ];
1019        for (hf, gguf) in cases {
1020            assert_eq!(
1021                hf_to_gguf_name(hf).as_deref(),
1022                Some(gguf),
1023                "mismatch for {hf}"
1024            );
1025        }
1026    }
1027
1028    #[test]
1029    fn hf_to_gguf_gemma4_output_scale() {
1030        assert_eq!(
1031            hf_to_gguf_name_for_arch("model.layers.5.self_attn.output_scale.weight", "gemma4")
1032                .as_deref(),
1033            Some("blk.5.layer_output_scale.weight")
1034        );
1035        assert_eq!(
1036            gguf_to_hf_name_for_arch("blk.5.layer_output_scale.weight", "gemma4").as_deref(),
1037            Some("model.layers.5.self_attn.output_scale.weight")
1038        );
1039    }
1040
1041    #[test]
1042    fn hf_to_gguf_unknown_returns_none() {
1043        assert!(hf_to_gguf_name("model.layers.0.some_new_thing.weight").is_none());
1044        assert!(hf_to_gguf_name("model.layers.foo.input_layernorm.weight").is_none());
1045    }
1046
1047    #[test]
1048    fn mtp_detection() {
1049        assert!(is_mtp_weight("mtp_blk.0.attn_q.weight"));
1050        assert!(is_mtp_weight("output_mtp_0.weight"));
1051        assert!(is_mtp_weight("model.layers.0.mtp_head.weight"));
1052        assert!(!is_mtp_weight("blk.0.attn_q.weight"));
1053        assert!(!is_mtp_weight("output.weight"));
1054    }
1055
1056    /// Build a tiny GGUF with `qwen35.block_count = 25` and
1057    /// `qwen35.nextn_predict_layers = 1`, then verify the loader's
1058    /// model-aware detector classifies `blk.24.*` as MTP while
1059    /// `blk.0.*` stays in the base model. This is the unsloth /
1060    /// DeepSeek-V3 convention — substring-based `is_mtp_weight`
1061    /// alone wouldn't catch it.
1062    #[test]
1063    fn ggml_block_quants_map_to_packed_scheme() {
1064        use rlx_gguf::GgmlType;
1065        assert_eq!(
1066            ggml_type_to_quant_scheme(GgmlType::Q4_0),
1067            Some(rlx_ir::quant::QuantScheme::GgufQ4_0)
1068        );
1069        assert_eq!(
1070            ggml_type_to_quant_scheme(GgmlType::Q5_0),
1071            Some(rlx_ir::quant::QuantScheme::GgufQ5_0)
1072        );
1073        assert_eq!(
1074            ggml_type_to_quant_scheme(GgmlType::Q8_0),
1075            Some(rlx_ir::quant::QuantScheme::GgufQ8_0)
1076        );
1077    }
1078
1079    #[test]
1080    fn q6k_dequant_matmul_follows_block_probe() {
1081        use rlx_ir::quant::QuantScheme;
1082        assert!(dequant_matmul_supported(QuantScheme::GgufQ4K));
1083        assert_eq!(
1084            dequant_matmul_supported(QuantScheme::GgufQ6K),
1085            super::probe_q6k_block_dequant()
1086        );
1087    }
1088
1089    #[test]
1090    fn gguf_loader_threshold_based_mtp_detection() {
1091        let mut buf: Vec<u8> = Vec::new();
1092        buf.extend_from_slice(&rlx_gguf::GGUF_MAGIC.to_le_bytes());
1093        buf.extend_from_slice(&3u32.to_le_bytes());
1094        buf.extend_from_slice(&3u64.to_le_bytes()); // 3 tensors
1095        buf.extend_from_slice(&3u64.to_le_bytes()); // 3 KV
1096        // KV: general.architecture = "qwen35" (type 8 = string)
1097        let write_string = |buf: &mut Vec<u8>, k: &str, v: &str| {
1098            buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
1099            buf.extend_from_slice(k.as_bytes());
1100            buf.extend_from_slice(&8u32.to_le_bytes());
1101            buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
1102            buf.extend_from_slice(v.as_bytes());
1103        };
1104        let write_u32 = |buf: &mut Vec<u8>, k: &str, v: u32| {
1105            buf.extend_from_slice(&(k.len() as u64).to_le_bytes());
1106            buf.extend_from_slice(k.as_bytes());
1107            buf.extend_from_slice(&4u32.to_le_bytes()); // type 4 = u32
1108            buf.extend_from_slice(&v.to_le_bytes());
1109        };
1110        write_string(&mut buf, "general.architecture", "qwen35");
1111        write_u32(&mut buf, "qwen35.block_count", 25);
1112        write_u32(&mut buf, "qwen35.nextn_predict_layers", 1);
1113        // Three tensors: blk.0.attn_q.weight (main), blk.24.attn_q.weight (MTP),
1114        // and token_embd.weight (always main).
1115        let write_tensor = |buf: &mut Vec<u8>, name: &str, shape: &[usize], off: u64| {
1116            buf.extend_from_slice(&(name.len() as u64).to_le_bytes());
1117            buf.extend_from_slice(name.as_bytes());
1118            buf.extend_from_slice(&(shape.len() as u32).to_le_bytes());
1119            for &d in shape {
1120                buf.extend_from_slice(&(d as u64).to_le_bytes());
1121            }
1122            buf.extend_from_slice(&0u32.to_le_bytes()); // F32
1123            buf.extend_from_slice(&off.to_le_bytes());
1124        };
1125        write_tensor(&mut buf, "blk.0.attn_q.weight", &[4, 4], 0);
1126        write_tensor(&mut buf, "blk.24.attn_q.weight", &[4, 4], 64);
1127        write_tensor(&mut buf, "token_embd.weight", &[4, 4], 128);
1128        while !buf
1129            .len()
1130            .is_multiple_of(rlx_gguf::DEFAULT_ALIGNMENT as usize)
1131        {
1132            buf.push(0);
1133        }
1134        // 3 × 4×4 f32 = 192 bytes of data.
1135        for _ in 0..(3 * 16) {
1136            buf.extend_from_slice(&0.5f32.to_le_bytes());
1137        }
1138        let path = std::env::temp_dir().join("rlx_mtp_threshold_test.gguf");
1139        std::fs::write(&path, &buf).unwrap();
1140        let loader = GgufLoader::from_file(path.to_str().unwrap()).unwrap();
1141
1142        assert_eq!(loader.mtp_layer_threshold(), Some(24));
1143        assert!(!loader.is_mtp_tensor("blk.0.attn_q.weight"));
1144        assert!(loader.is_mtp_tensor("blk.24.attn_q.weight"));
1145        assert!(!loader.is_mtp_tensor("token_embd.weight"));
1146        let mtp = loader.mtp_keys();
1147        assert_eq!(mtp, vec!["blk.24.attn_q.weight".to_string()]);
1148
1149        std::fs::remove_file(&path).ok();
1150    }
1151
1152    /// Synthesize a tiny GGUF file in memory with two GGUF-named
1153    /// tensors (`token_embd.weight` and `blk.0.attn_q.weight`) plus
1154    /// one MTP weight (`output_mtp_0.weight`). Then verify:
1155    ///   1. `take()` resolves the HF names via the mapper.
1156    ///   2. `remaining_keys()` hides the MTP weight.
1157    ///   3. `mtp_keys()` surfaces it for callers that opt in.
1158    #[test]
1159    fn gguf_loader_resolves_hf_names_and_skips_mtp() {
1160        let mut tensors = Vec::new();
1161        let mut data: Vec<f32> = Vec::new();
1162
1163        // tensor #1: token_embd.weight, shape [3, 4], values 0..12
1164        let t1: Vec<f32> = (0..12).map(|x| x as f32).collect();
1165        tensors.push(("token_embd.weight", vec![3usize, 4], data.len()));
1166        data.extend_from_slice(&t1);
1167
1168        // tensor #2: blk.0.attn_q.weight, shape [4, 4], values 100..116
1169        let t2: Vec<f32> = (100..116).map(|x| x as f32).collect();
1170        tensors.push(("blk.0.attn_q.weight", vec![4usize, 4], data.len()));
1171        data.extend_from_slice(&t2);
1172
1173        // tensor #3: output_mtp_0.weight (MTP head) — present but skipped
1174        let t3: Vec<f32> = vec![0.5f32; 8];
1175        tensors.push(("output_mtp_0.weight", vec![2usize, 4], data.len()));
1176        data.extend_from_slice(&t3);
1177
1178        // Build the GGUF byte stream by hand.
1179        let mut buf: Vec<u8> = Vec::new();
1180        buf.extend_from_slice(&rlx_gguf::GGUF_MAGIC.to_le_bytes());
1181        buf.extend_from_slice(&3u32.to_le_bytes()); // version
1182        buf.extend_from_slice(&(tensors.len() as u64).to_le_bytes());
1183        buf.extend_from_slice(&0u64.to_le_bytes()); // kv_count
1184
1185        // Tensor info section.
1186        for (name, shape, _) in &tensors {
1187            buf.extend_from_slice(&(name.len() as u64).to_le_bytes());
1188            buf.extend_from_slice(name.as_bytes());
1189            buf.extend_from_slice(&(shape.len() as u32).to_le_bytes());
1190            for &d in shape {
1191                buf.extend_from_slice(&(d as u64).to_le_bytes());
1192            }
1193            buf.extend_from_slice(&0u32.to_le_bytes()); // dtype = F32
1194            // Offset within the data segment — patched after alignment.
1195            buf.extend_from_slice(&0u64.to_le_bytes());
1196        }
1197        // Align to DEFAULT_ALIGNMENT before data section.
1198        while !buf
1199            .len()
1200            .is_multiple_of(rlx_gguf::DEFAULT_ALIGNMENT as usize)
1201        {
1202            buf.push(0);
1203        }
1204        let data_start = buf.len();
1205        for v in &data {
1206            buf.extend_from_slice(&v.to_le_bytes());
1207        }
1208        // Patch the offsets we wrote as 0 above.
1209        let header = (4 + 4 + 8 + 8) as usize; // magic + version + tensor_count + kv_count
1210        let mut cursor = header;
1211        for (name, shape, byte_off) in &tensors {
1212            let name_len_bytes = 8;
1213            let name_bytes = name.len();
1214            let n_dims_bytes = 4;
1215            let dims_bytes = shape.len() * 8;
1216            let dtype_bytes = 4;
1217            let off_bytes = 8;
1218            let info_size =
1219                name_len_bytes + name_bytes + n_dims_bytes + dims_bytes + dtype_bytes + off_bytes;
1220            let off_field_at = cursor + info_size - off_bytes;
1221            let final_off = (*byte_off * 4) as u64; // f32 byte offset within data segment
1222            for i in 0..8 {
1223                buf[off_field_at + i] = (final_off >> (i * 8)) as u8;
1224            }
1225            cursor += info_size;
1226        }
1227        let _ = data_start;
1228
1229        // Write to a temp file (GgufFile reads from a path).
1230        let path = std::env::temp_dir().join("rlx_test_qwen3_mini.gguf");
1231        std::fs::write(&path, &buf).unwrap();
1232
1233        let mut loader = GgufLoader::from_file(path.to_str().unwrap()).unwrap();
1234        // Pre-MTP: 3 tensors total, MTP is hidden so visible count = 2.
1235        assert_eq!(loader.len(), 2);
1236
1237        // HF-name lookup resolves via the mapper. GGUF reports shapes
1238        // innermost-first while safetensors reports outermost-first;
1239        // byte layout is identical, only the shape label flips. The
1240        // synthetic GGUF here was built with shape `[3, 4]`, so the
1241        // loader hands back `[4, 3]` with the same bytes.
1242        let (out, shape) = loader
1243            .take("model.embed_tokens.weight")
1244            .expect("hf-named token_embd should resolve");
1245        assert_eq!(shape, vec![4, 3]);
1246        assert_eq!(&out, &t1);
1247
1248        let (out, shape) = loader
1249            .take("model.layers.0.self_attn.q_proj.weight")
1250            .expect("hf-named attn_q should resolve");
1251        assert_eq!(shape, vec![4, 4]);
1252        assert_eq!(&out, &t2);
1253
1254        // MTP weight stays out of remaining_keys, in mtp_keys.
1255        assert_eq!(loader.remaining_keys(), Vec::<String>::new());
1256        assert_eq!(loader.mtp_keys(), vec!["output_mtp_0.weight".to_string()]);
1257
1258        // include_mtp(true): MTP weights become visible in
1259        // remaining_keys + drainable via take(), and `take_mtp`
1260        // works for explicit grabs without the flag.
1261        let mut loader2 = GgufLoader::from_file(path.to_str().unwrap()).unwrap();
1262        loader2.include_mtp(true);
1263        let visible: std::collections::HashSet<String> =
1264            loader2.remaining_keys().into_iter().collect();
1265        assert!(visible.contains("token_embd.weight"));
1266        assert!(visible.contains("blk.0.attn_q.weight"));
1267        assert!(
1268            visible.contains("output_mtp_0.weight"),
1269            "MTP weight should be visible with include_mtp(true)"
1270        );
1271        let (mtp_data, mtp_shape) = loader2.take_mtp("output_mtp_0.weight").unwrap();
1272        assert_eq!(mtp_shape, vec![4, 2]);
1273        assert_eq!(mtp_data, t3);
1274
1275        // include_mtp(false) — default — refuses MTP via take().
1276        let mut loader3 = GgufLoader::from_file(path.to_str().unwrap()).unwrap();
1277        let err = loader3.take("output_mtp_0.weight").unwrap_err();
1278        let msg = format!("{err:#}");
1279        assert!(
1280            msg.contains("include_mtp(true)"),
1281            "expected MTP guard error, got: {msg}"
1282        );
1283
1284        std::fs::remove_file(&path).ok();
1285    }
1286
1287    #[test]
1288    fn missing_gguf_file_errors() {
1289        // .gguf is now a known extension → error comes from `open`,
1290        // not from the dispatcher.
1291        let r = load_from_path("/tmp/no-such-thing-rlx-gguf-test.gguf");
1292        assert!(r.is_err());
1293    }
1294}