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